Skip to content

Commit 88a73c1

Browse files
Add UUID validation for consentId
- Added path escaping when forwarding `consentId`. - Added invalid-consent-ID integration coverage. - Updated integration fixtures to use valid UUID consent IDs.
1 parent fbe1d39 commit 88a73c1

3 files changed

Lines changed: 141 additions & 31 deletions

File tree

portal/backend/internal/proxy/handler.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"io"
2525
"net/http"
2626
"net/url"
27+
"regexp"
2728
"strconv"
2829
"strings"
2930

@@ -43,6 +44,7 @@ type errorResponse struct {
4344
}
4445

4546
var errRequestBodyTooLarge = errors.New("request body too large")
47+
var consentIDPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
4648

4749
type consentRetrievalResponse struct {
4850
ID string `json:"id"`
@@ -209,8 +211,12 @@ func (h *Handler) MeConsentByID(w http.ResponseWriter, r *http.Request) {
209211
writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent id not found")
210212
return
211213
}
214+
if !isValidConsentID(consentID) {
215+
writeJSONError(w, http.StatusBadRequest, "INVALID_CONSENT_ID", "invalid consent id")
216+
return
217+
}
212218

213-
baseResp, err := h.svc.ForwardRaw(r, http.MethodGet, "/api/v1/consents/"+consentID, nil, nil)
219+
baseResp, err := h.svc.ForwardRaw(r, http.MethodGet, "/api/v1/consents/"+url.PathEscape(consentID), nil, nil)
214220
if err != nil {
215221
h.writeProxyError(w, err)
216222
return
@@ -247,6 +253,10 @@ func (h *Handler) MeConsentApprove(w http.ResponseWriter, r *http.Request) {
247253
writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent id not found")
248254
return
249255
}
256+
if !isValidConsentID(consentID) {
257+
writeJSONError(w, http.StatusBadRequest, "INVALID_CONSENT_ID", "invalid consent id")
258+
return
259+
}
250260
body, err := h.readBoundedBody(r)
251261
if err != nil {
252262
writeBodyReadError(w, err)
@@ -257,7 +267,7 @@ func (h *Handler) MeConsentApprove(w http.ResponseWriter, r *http.Request) {
257267
writeJSONError(w, http.StatusBadRequest, "INVALID_PAYLOAD", "invalid request payload")
258268
return
259269
}
260-
baseResp, err := h.svc.ForwardRaw(r, http.MethodGet, "/api/v1/consents/"+consentID, nil, nil)
270+
baseResp, err := h.svc.ForwardRaw(r, http.MethodGet, "/api/v1/consents/"+url.PathEscape(consentID), nil, nil)
261271
if err != nil {
262272
h.writeProxyError(w, err)
263273
return
@@ -276,7 +286,7 @@ func (h *Handler) MeConsentApprove(w http.ResponseWriter, r *http.Request) {
276286
writeJSONError(w, http.StatusBadRequest, "INVALID_PAYLOAD", "invalid request payload")
277287
return
278288
}
279-
if err := h.svc.ForwardWithClientID(w, r, http.MethodPut, "/api/v1/consents/"+consentID, nil, payload, trustedClientID); err != nil {
289+
if err := h.svc.ForwardWithClientID(w, r, http.MethodPut, "/api/v1/consents/"+url.PathEscape(consentID), nil, payload, trustedClientID); err != nil {
280290
h.writeProxyError(w, err)
281291
}
282292
}
@@ -297,6 +307,10 @@ func (h *Handler) MeConsentRevoke(w http.ResponseWriter, r *http.Request) {
297307
writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent id not found")
298308
return
299309
}
310+
if !isValidConsentID(consentID) {
311+
writeJSONError(w, http.StatusBadRequest, "INVALID_CONSENT_ID", "invalid consent id")
312+
return
313+
}
300314
body, err := h.readBoundedBody(r)
301315
if err != nil {
302316
writeBodyReadError(w, err)
@@ -307,11 +321,15 @@ func (h *Handler) MeConsentRevoke(w http.ResponseWriter, r *http.Request) {
307321
writeJSONError(w, http.StatusBadRequest, "INVALID_PAYLOAD", "invalid request payload")
308322
return
309323
}
310-
if err := h.svc.Forward(w, r, http.MethodPut, "/api/v1/consents/"+consentID+"/revoke", nil, payload); err != nil {
324+
if err := h.svc.Forward(w, r, http.MethodPut, "/api/v1/consents/"+url.PathEscape(consentID)+"/revoke", nil, payload); err != nil {
311325
h.writeProxyError(w, err)
312326
}
313327
}
314328

329+
func isValidConsentID(id string) bool {
330+
return consentIDPattern.MatchString(strings.TrimSpace(id))
331+
}
332+
315333
func (h *Handler) writeProxyError(w http.ResponseWriter, err error) {
316334
if errors.Is(err, ErrUpstreamTimeout) {
317335
writeJSONError(w, http.StatusGatewayTimeout, "UPSTREAM_TIMEOUT", "upstream timeout")
@@ -754,4 +772,3 @@ func findAuthorizationIndexToUpdate(authorizations []consentAuthorizationEntry,
754772

755773
return -1, false
756774
}
757-
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
3+
*
4+
* WSO2 LLC. licenses this file to you under the Apache License,
5+
* Version 2.0 (the "License"); you may not use this file except
6+
* in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing,
12+
* software distributed under the License is distributed on an
13+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
* KIND, either express or implied. See the License for the
15+
* specific language governing permissions and limitations
16+
* under the License.
17+
*/
18+
19+
package integration
20+
21+
import (
22+
"encoding/json"
23+
"io"
24+
"net/http"
25+
"net/http/httptest"
26+
"strings"
27+
"testing"
28+
)
29+
30+
func TestMeEndpointsRejectInvalidConsentID(t *testing.T) {
31+
upstreamCalled := false
32+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
33+
upstreamCalled = true
34+
w.WriteHeader(http.StatusOK)
35+
}))
36+
defer upstream.Close()
37+
38+
bff := newPhase2Server(t, upstream.URL)
39+
defer bff.Close()
40+
41+
testCases := []struct {
42+
name string
43+
method string
44+
path string
45+
body string
46+
}{
47+
{name: "get by id", method: http.MethodGet, path: "/me/consents/not-a-uuid"},
48+
{name: "approve", method: http.MethodPost, path: "/me/consents/not-a-uuid/approve", body: "[]"},
49+
{name: "revoke", method: http.MethodPut, path: "/me/consents/not-a-uuid/revoke", body: "{}"},
50+
}
51+
52+
for _, tc := range testCases {
53+
t.Run(tc.name, func(t *testing.T) {
54+
upstreamCalled = false
55+
56+
var body io.Reader
57+
if tc.body != "" {
58+
body = strings.NewReader(tc.body)
59+
}
60+
61+
req, err := http.NewRequest(tc.method, bff.URL+tc.path, body)
62+
if err != nil {
63+
t.Fatalf("request creation failed: %v", err)
64+
}
65+
if tc.body != "" {
66+
req.Header.Set("Content-Type", "application/json")
67+
}
68+
69+
resp, err := http.DefaultClient.Do(req)
70+
if err != nil {
71+
t.Fatalf("request failed: %v", err)
72+
}
73+
defer func() {
74+
_ = resp.Body.Close()
75+
}()
76+
77+
if resp.StatusCode != http.StatusBadRequest {
78+
t.Fatalf("expected 400, got %d", resp.StatusCode)
79+
}
80+
if upstreamCalled {
81+
t.Fatal("expected request to be rejected before upstream call")
82+
}
83+
84+
var payload map[string]any
85+
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
86+
t.Fatalf("expected json error payload: %v", err)
87+
}
88+
if payload["code"] != "INVALID_CONSENT_ID" {
89+
t.Fatalf("expected INVALID_CONSENT_ID, got %v", payload["code"])
90+
}
91+
})
92+
}
93+
}

portal/backend/tests/integration/proxy_phase2_test.go

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ func TestMeConsentByIDStripsHopByHopHeadersFromUpstreamError(t *testing.T) {
229229
bff := newPhase2Server(t, upstream.URL)
230230
defer bff.Close()
231231

232-
resp, err := http.Get(bff.URL + "/me/consents/missing")
232+
resp, err := http.Get(bff.URL + "/me/consents/550e8400-e29b-41d4-a716-446655440999")
233233
if err != nil {
234234
t.Fatalf("request failed: %v", err)
235235
}
@@ -259,11 +259,11 @@ func TestApproveAndRevokeMappings(t *testing.T) {
259259

260260
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
261261
switch {
262-
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/consents/consent-123":
262+
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/consents/550e8400-e29b-41d4-a716-446655440000":
263263
w.Header().Set("Content-Type", "application/json")
264264
w.WriteHeader(http.StatusOK)
265265
_, _ = w.Write([]byte(`{
266-
"id":"consent-123",
266+
"id":"550e8400-e29b-41d4-a716-446655440000",
267267
"clientId":"TPP-CLIENT-001",
268268
"type":"accounts",
269269
"status":"CREATED",
@@ -295,7 +295,7 @@ func TestApproveAndRevokeMappings(t *testing.T) {
295295
]}
296296
]
297297
}`))
298-
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/consents/consent-123":
298+
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/consents/550e8400-e29b-41d4-a716-446655440000":
299299
gotMethod = r.Method
300300
gotPath = r.URL.Path
301301
gotTPPClientID = r.Header.Get("TPP-client-id")
@@ -326,7 +326,7 @@ func TestApproveAndRevokeMappings(t *testing.T) {
326326
defer bff.Close()
327327

328328
payload := []byte(`[{"purposeName":"profile_access","elementName":"last_name"}]`)
329-
resp, err := http.Post(bff.URL+"/me/consents/consent-123/approve", "application/json", bytes.NewReader(payload))
329+
resp, err := http.Post(bff.URL+"/me/consents/550e8400-e29b-41d4-a716-446655440000/approve", "application/json", bytes.NewReader(payload))
330330
if err != nil {
331331
t.Fatalf("request failed: %v", err)
332332
}
@@ -340,7 +340,7 @@ func TestApproveAndRevokeMappings(t *testing.T) {
340340
if gotMethod != http.MethodPut {
341341
t.Fatalf("expected PUT, got %s", gotMethod)
342342
}
343-
if gotPath != "/api/v1/consents/consent-123" {
343+
if gotPath != "/api/v1/consents/550e8400-e29b-41d4-a716-446655440000" {
344344
t.Fatalf("unexpected path: %s", gotPath)
345345
}
346346
if gotTPPClientID != "TPP-CLIENT-001" {
@@ -439,7 +439,7 @@ func TestApproveAndRevokeMappings(t *testing.T) {
439439
bff := newPhase2Server(t, upstream.URL)
440440
defer bff.Close()
441441

442-
req, err := http.NewRequest(http.MethodPut, bff.URL+"/me/consents/consent-123/revoke", bytes.NewReader([]byte(`{"revocationReason":"test"}`)))
442+
req, err := http.NewRequest(http.MethodPut, bff.URL+"/me/consents/550e8400-e29b-41d4-a716-446655440000/revoke", bytes.NewReader([]byte(`{"revocationReason":"test"}`)))
443443
if err != nil {
444444
t.Fatalf("request creation failed: %v", err)
445445
}
@@ -458,7 +458,7 @@ func TestApproveAndRevokeMappings(t *testing.T) {
458458
if gotMethod != http.MethodPut {
459459
t.Fatalf("expected PUT, got %s", gotMethod)
460460
}
461-
if gotPath != "/api/v1/consents/consent-123/revoke" {
461+
if gotPath != "/api/v1/consents/550e8400-e29b-41d4-a716-446655440000/revoke" {
462462
t.Fatalf("unexpected path: %s", gotPath)
463463
}
464464
if gotBody["actionBy"] != "user@example.com" {
@@ -486,7 +486,7 @@ func TestApproveAndRevokeMappings(t *testing.T) {
486486
bff := newPhase2Server(t, upstream.URL)
487487
defer bff.Close()
488488

489-
req, err := http.NewRequest(http.MethodPut, bff.URL+"/me/consents/consent-123/revoke", bytes.NewReader([]byte(`null`)))
489+
req, err := http.NewRequest(http.MethodPut, bff.URL+"/me/consents/550e8400-e29b-41d4-a716-446655440000/revoke", bytes.NewReader([]byte(`null`)))
490490
if err != nil {
491491
t.Fatalf("request creation failed: %v", err)
492492
}
@@ -505,7 +505,7 @@ func TestApproveAndRevokeMappings(t *testing.T) {
505505
if gotMethod != http.MethodPut {
506506
t.Fatalf("expected PUT, got %s", gotMethod)
507507
}
508-
if gotPath != "/api/v1/consents/consent-123/revoke" {
508+
if gotPath != "/api/v1/consents/550e8400-e29b-41d4-a716-446655440000/revoke" {
509509
t.Fatalf("unexpected path: %s", gotPath)
510510
}
511511
if gotBody["actionBy"] != "user@example.com" {
@@ -646,7 +646,7 @@ func TestRequestBodySizeLimitReturns413(t *testing.T) {
646646

647647
t.Run("me approve returns 413 with json error", func(t *testing.T) {
648648
big := bytes.Repeat([]byte("b"), 64)
649-
resp, err := http.Post(bff.URL+"/me/consents/c-1/approve", "application/json", bytes.NewReader(big))
649+
resp, err := http.Post(bff.URL+"/me/consents/550e8400-e29b-41d4-a716-446655440001/approve", "application/json", bytes.NewReader(big))
650650
if err != nil {
651651
t.Fatalf("request failed: %v", err)
652652
}
@@ -750,9 +750,9 @@ func TestMeEndpointsReturn503WhenPlaceholderModeDisabled(t *testing.T) {
750750
body string
751751
}{
752752
{name: "me consents", method: http.MethodGet, path: "/me/consents"},
753-
{name: "me consent by id", method: http.MethodGet, path: "/me/consents/consent-123"},
754-
{name: "me approve", method: http.MethodPost, path: "/me/consents/consent-123/approve", body: "[]"},
755-
{name: "me revoke", method: http.MethodPut, path: "/me/consents/consent-123/revoke", body: "{}"},
753+
{name: "me consent by id", method: http.MethodGet, path: "/me/consents/550e8400-e29b-41d4-a716-446655440000"},
754+
{name: "me approve", method: http.MethodPost, path: "/me/consents/550e8400-e29b-41d4-a716-446655440000/approve", body: "[]"},
755+
{name: "me revoke", method: http.MethodPut, path: "/me/consents/550e8400-e29b-41d4-a716-446655440000/revoke", body: "{}"},
756756
}
757757

758758
for _, tc := range testCases {
@@ -802,11 +802,11 @@ func TestMeEndpointsReturn503WhenPlaceholderModeDisabled(t *testing.T) {
802802
func TestMeConsentByIDAggregatesPurposeAndElementDetails(t *testing.T) {
803803
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
804804
switch r.URL.Path {
805-
case "/api/v1/consents/consent-123":
805+
case "/api/v1/consents/550e8400-e29b-41d4-a716-446655440000":
806806
w.Header().Set("Content-Type", "application/json")
807807
w.WriteHeader(http.StatusOK)
808808
_, _ = w.Write([]byte(`{
809-
"id":"consent-123",
809+
"id":"550e8400-e29b-41d4-a716-446655440000",
810810
"clientId":"TPP-CLIENT-001",
811811
"type":"accounts",
812812
"status":"ACTIVE",
@@ -857,7 +857,7 @@ func TestMeConsentByIDAggregatesPurposeAndElementDetails(t *testing.T) {
857857
bff := newPhase2Server(t, upstream.URL)
858858
defer bff.Close()
859859

860-
resp, err := http.Get(bff.URL + "/me/consents/consent-123")
860+
resp, err := http.Get(bff.URL + "/me/consents/550e8400-e29b-41d4-a716-446655440000")
861861
if err != nil {
862862
t.Fatalf("request failed: %v", err)
863863
}
@@ -908,11 +908,11 @@ func TestMeConsentByIDAggregatesPurposeAndElementDetails(t *testing.T) {
908908
func TestMeConsentByIDFailsClosedWhenPurposeMetadataMissing(t *testing.T) {
909909
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
910910
switch r.URL.Path {
911-
case "/api/v1/consents/consent-123":
911+
case "/api/v1/consents/550e8400-e29b-41d4-a716-446655440000":
912912
w.Header().Set("Content-Type", "application/json")
913913
w.WriteHeader(http.StatusOK)
914914
_, _ = w.Write([]byte(`{
915-
"id":"consent-123",
915+
"id":"550e8400-e29b-41d4-a716-446655440000",
916916
"clientId":"TPP-CLIENT-001",
917917
"type":"accounts",
918918
"status":"ACTIVE",
@@ -933,7 +933,7 @@ func TestMeConsentByIDFailsClosedWhenPurposeMetadataMissing(t *testing.T) {
933933
bff := newPhase2Server(t, upstream.URL)
934934
defer bff.Close()
935935

936-
resp, err := http.Get(bff.URL + "/me/consents/consent-123")
936+
resp, err := http.Get(bff.URL + "/me/consents/550e8400-e29b-41d4-a716-446655440000")
937937
if err != nil {
938938
t.Fatalf("request failed: %v", err)
939939
}
@@ -957,11 +957,11 @@ func TestMeConsentByIDFailsClosedWhenPurposeMetadataMissing(t *testing.T) {
957957
func TestMeConsentByIDHandlesNullableAndMixedProperties(t *testing.T) {
958958
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
959959
switch r.URL.Path {
960-
case "/api/v1/consents/consent-123":
960+
case "/api/v1/consents/550e8400-e29b-41d4-a716-446655440000":
961961
w.Header().Set("Content-Type", "application/json")
962962
w.WriteHeader(http.StatusOK)
963963
_, _ = w.Write([]byte(`{
964-
"id":"consent-123",
964+
"id":"550e8400-e29b-41d4-a716-446655440000",
965965
"clientId":"TPP-CLIENT-001",
966966
"type":"accounts",
967967
"status":"ACTIVE",
@@ -996,7 +996,7 @@ func TestMeConsentByIDHandlesNullableAndMixedProperties(t *testing.T) {
996996
bff := newPhase2Server(t, upstream.URL)
997997
defer bff.Close()
998998

999-
resp, err := http.Get(bff.URL + "/me/consents/consent-123")
999+
resp, err := http.Get(bff.URL + "/me/consents/550e8400-e29b-41d4-a716-446655440000")
10001000
if err != nil {
10011001
t.Fatalf("request failed: %v", err)
10021002
}
@@ -1039,11 +1039,11 @@ func TestMeConsentByIDHandlesNullableAndMixedProperties(t *testing.T) {
10391039
func TestMeConsentByIDPurposeLookupFallsBackWithoutClientFilter(t *testing.T) {
10401040
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
10411041
switch r.URL.Path {
1042-
case "/api/v1/consents/consent-123":
1042+
case "/api/v1/consents/550e8400-e29b-41d4-a716-446655440000":
10431043
w.Header().Set("Content-Type", "application/json")
10441044
w.WriteHeader(http.StatusOK)
10451045
_, _ = w.Write([]byte(`{
1046-
"id":"consent-123",
1046+
"id":"550e8400-e29b-41d4-a716-446655440000",
10471047
"clientId":"TPP-CLIENT-003",
10481048
"type":"accounts",
10491049
"status":"ACTIVE",
@@ -1074,7 +1074,7 @@ func TestMeConsentByIDPurposeLookupFallsBackWithoutClientFilter(t *testing.T) {
10741074
bff := newPhase2Server(t, upstream.URL)
10751075
defer bff.Close()
10761076

1077-
resp, err := http.Get(bff.URL + "/me/consents/consent-123")
1077+
resp, err := http.Get(bff.URL + "/me/consents/550e8400-e29b-41d4-a716-446655440000")
10781078
if err != nil {
10791079
t.Fatalf("request failed: %v", err)
10801080
}

0 commit comments

Comments
 (0)