From bb219b262bd2e5ef1f9a487aebb56fadbdc537c4 Mon Sep 17 00:00:00 2001 From: akshaydeo Date: Sat, 1 Aug 2026 22:22:34 -0700 Subject: [PATCH] auth path whitelisting --- cli/go.mod | 3 + cli/go.sum | 9 +- plugins/semanticcache/go.mod | 2 +- .../e2e/api/collections/provider-harness.json | 409 +++++++++++++++++- .../api/provider_config/path-auth/config.json | 15 + .../bifrost-http/handlers/middlewares.go | 19 +- .../bifrost-http/handlers/middlewares_test.go | 151 +++++++ 7 files changed, 602 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/api/provider_config/path-auth/config.json diff --git a/cli/go.mod b/cli/go.mod index 936bf7c5da4..35976b9be7f 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -34,6 +34,7 @@ require ( github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -43,6 +44,7 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect @@ -50,4 +52,5 @@ require ( golang.org/x/arch v0.23.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/text v0.41.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/cli/go.sum b/cli/go.sum index f12ddab4050..8be42af00ac 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -32,6 +32,7 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= @@ -48,6 +49,9 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= @@ -64,11 +68,14 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -98,8 +105,8 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/semanticcache/go.mod b/plugins/semanticcache/go.mod index b9b64d856df..c34cccc8bf4 100644 --- a/plugins/semanticcache/go.mod +++ b/plugins/semanticcache/go.mod @@ -3,6 +3,7 @@ module github.com/maximhq/bifrost/plugins/semanticcache go 1.27.0 require ( + github.com/bytedance/sonic v1.15.3-0.20260730064818-2a36d6da63e2 github.com/cespare/xxhash/v2 v2.3.0 github.com/google/uuid v1.6.0 github.com/maximhq/bifrost/core v1.10.1 @@ -41,7 +42,6 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.2.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect - github.com/bytedance/sonic v1.15.3-0.20260730064818-2a36d6da63e2 // indirect github.com/bytedance/sonic/loader v0.5.2 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect diff --git a/tests/e2e/api/collections/provider-harness.json b/tests/e2e/api/collections/provider-harness.json index 0ca3a4a4717..b4be21a0375 100644 --- a/tests/e2e/api/collections/provider-harness.json +++ b/tests/e2e/api/collections/provider-harness.json @@ -59,7 +59,7 @@ "// Only register the content-shape test when the request actually succeeded.", "// Otherwise newman reports it as passing (because of an early return) which is misleading next to a failing status.", "var __u = (pm.request && pm.request.url && pm.request.url.toString()) || '';", - "var __skipShape = (__u.indexOf('/files/') !== -1 && (__u.indexOf('/content?') !== -1 || __u.slice(-8) === '/content')) || __u.indexOf('storage.googleapis.com') !== -1 || __u.indexOf('batchPredictionJobs') !== -1 || (__u.indexOf('/genai/v1beta/batches/') !== -1 && __u.indexOf(':cancel') !== -1);", + "var __skipShape = __u.indexOf('/oauth2/') !== -1 || (__u.indexOf('/files/') !== -1 && (__u.indexOf('/content?') !== -1 || __u.slice(-8) === '/content')) || __u.indexOf('storage.googleapis.com') !== -1 || __u.indexOf('batchPredictionJobs') !== -1 || (__u.indexOf('/genai/v1beta/batches/') !== -1 && __u.indexOf(':cancel') !== -1);", "if (pm.response.code < 400 && !__skipShape && (pm.response.headers.get('content-type') || '').indexOf('text/event-stream') === -1 && (pm.response.headers.get('content-type') || '').indexOf('vnd.amazon.eventstream') === -1 && (pm.response.headers.get('content-type') || '').indexOf('audio/') === -1) {", " pm.test('Response has content (text or tool_use)', function () {", " var j;", @@ -167393,6 +167393,413 @@ } } ] + }, + { + "name": "114. Raw Path Authorization (PR #5765)", + "description": "Opt-in auth-profile regression for GHSA-m82j-pr23-9hph and the analogous scoped-temp-token bypass. Run with provider_config/path-auth/config.json on an isolated fresh instance, INCLUDE_PREVIEW=1, PARALLEL=0 and FEATURE=raw-path-auth. The openai keyword is only a harness filter label; no provider calls are made. Cookies, inherited auth, and redirect following are disabled. Requires dashboard auth and mcp_enable_temp_token_auth. Whitelist probes use read-only GETs or invalid JSON, so a vulnerable handler cannot create provider/plugin configuration. Public OAuth registration/authorization creates only disposable client and consent-flow rows in the isolated test database. The successful consent GET proves the token is valid before testing scope escape. Every unexpected status fails. Run the whole folder in order, not individual rows.", + "item": [ + { + "name": "[PREVIEW] [EXPECT-401] openai raw-path-auth dashboard auth precondition - #5765", + "protocolProfileBehavior": { + "followRedirects": false, + "disableCookies": true + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('raw-path-auth exact response status', function () {", + " pm.expect(pm.response.code, 'failed: ' + pm.response.text()).to.equal(401);", + "});" + ] + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": "{{baseUrl}}/api/config" + } + }, + { + "name": "[PREVIEW] [EXPECT-401] openai raw-path-auth provider update traversal - #5765", + "protocolProfileBehavior": { + "followRedirects": false, + "disableCookies": true + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('raw-path-auth exact response status', function () {", + " pm.expect(pm.response.code, 'failed: ' + pm.response.text()).to.equal(401);", + "});" + ] + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/api/providers/..%2Fskills%2Fserve%2Fpath-auth-regression", + "body": { + "mode": "raw", + "raw": "{" + } + } + }, + { + "name": "[PREVIEW] [EXPECT-401] openai raw-path-auth provider key traversal - #5765", + "protocolProfileBehavior": { + "followRedirects": false, + "disableCookies": true + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('raw-path-auth exact response status', function () {", + " pm.expect(pm.response.code, 'failed: ' + pm.response.text()).to.equal(401);", + "});" + ] + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/api/providers/..%2Fskills%2Fserve%2Fpath-auth-regression/keys", + "body": { + "mode": "raw", + "raw": "{" + } + } + }, + { + "name": "[PREVIEW] [EXPECT-401] openai raw-path-auth provider read lowercase traversal - #5765", + "protocolProfileBehavior": { + "followRedirects": false, + "disableCookies": true + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('raw-path-auth exact response status', function () {", + " pm.expect(pm.response.code, 'failed: ' + pm.response.text()).to.equal(401);", + "});" + ] + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": "{{baseUrl}}/api/providers/%2e%2e%2fskills%2fserve%2fpath-auth-regression?source=test" + } + }, + { + "name": "[PREVIEW] [EXPECT-401] openai raw-path-auth plugin dev traversal - #5765", + "protocolProfileBehavior": { + "followRedirects": false, + "disableCookies": true + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('raw-path-auth exact response status', function () {", + " pm.expect(pm.response.code, 'failed: ' + pm.response.text()).to.equal(401);", + "});" + ] + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": "{{baseUrl}}/api/plugins/..%2Fdev%2Fpath-auth-regression" + } + }, + { + "name": "[PREVIEW] [EXPECT-201] openai raw-path-auth register consent client - #5765", + "protocolProfileBehavior": { + "followRedirects": false, + "disableCookies": true + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.collectionVariables.unset('pathAuthClient');", + "pm.collectionVariables.unset('pathAuthFlow');", + "pm.collectionVariables.unset('pathAuthToken');" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('raw-path-auth exact response status', function () {", + " pm.expect(pm.response.code, 'failed: ' + pm.response.text()).to.equal(201);", + "});", + "pm.test('consent client created', function () {", + " var body = pm.response.json();", + " pm.expect(body.client_id).to.be.a('string').and.not.empty;", + " pm.collectionVariables.set('pathAuthClient', body.client_id);", + "});" + ] + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{baseUrl}}/oauth2/register", + "body": { + "mode": "raw", + "raw": "{\"client_name\":\"raw-path-auth regression\",\"redirect_uris\":[\"https://harness.example/cb\"],\"token_endpoint_auth_method\":\"none\"}" + } + } + }, + { + "name": "[PREVIEW] [EXPECT-302] openai raw-path-auth obtain consent token - #5765", + "protocolProfileBehavior": { + "followRedirects": false, + "disableCookies": true + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.expect(pm.collectionVariables.get('pathAuthClient'), 'consent client missing').to.be.a('string').and.not.empty;" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('raw-path-auth exact response status', function () {", + " pm.expect(pm.response.code, 'failed: ' + pm.response.text()).to.equal(302);", + "});", + "pm.test('redirect carries a scoped consent token', function () {", + " var location = pm.response.headers.get('Location') || '';", + " pm.expect(location).to.include('/oauth/consent?flow=');", + " pm.expect(location).not.to.include('error=');", + " var flow = location.match(/[?&]flow=([^&#]+)/);", + " var token = location.match(/#t=([^&]+)/);", + " pm.expect(flow, 'flow missing in redirect').not.to.equal(null);", + " pm.expect(token, 'token missing in redirect').not.to.equal(null);", + " pm.collectionVariables.set('pathAuthFlow', decodeURIComponent(flow[1]));", + " pm.collectionVariables.set('pathAuthToken', decodeURIComponent(token[1]));", + "});" + ] + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": "{{baseUrl}}/oauth2/authorize?client_id={{pathAuthClient}}&redirect_uri=https%3A%2F%2Fharness.example%2Fcb&response_type=code&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256" + } + }, + { + "name": "[PREVIEW] [EXPECT-200] openai raw-path-auth legitimate consent token control - #5765", + "protocolProfileBehavior": { + "followRedirects": false, + "disableCookies": true + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.expect(pm.collectionVariables.get('pathAuthToken'), 'run the entire raw-path-auth folder to obtain a valid token').to.be.a('string').and.not.empty;", + "pm.expect(pm.collectionVariables.get('pathAuthFlow'), 'consent flow missing').to.be.a('string').and.not.empty;" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('raw-path-auth exact response status', function () {", + " pm.expect(pm.response.code, 'failed: ' + pm.response.text()).to.equal(200);", + "});", + "pm.test('valid token reaches its bound consent flow', function () {", + " var body = pm.response.json();", + " pm.expect(body.client_name).to.equal('raw-path-auth regression');", + " pm.expect(body.available_modes).to.be.an('array');", + "});" + ] + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "X-Bifrost-Temp-Token", + "value": "{{pathAuthToken}}" + } + ], + "url": "{{baseUrl}}/api/oauth2/consent/flows/{{pathAuthFlow}}" + } + }, + { + "name": "[PREVIEW] [EXPECT-401] openai raw-path-auth scoped token provider traversal - #5765", + "protocolProfileBehavior": { + "followRedirects": false, + "disableCookies": true + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.expect(pm.collectionVariables.get('pathAuthToken'), 'run the entire raw-path-auth folder to obtain a valid token').to.be.a('string').and.not.empty;", + "pm.expect(pm.collectionVariables.get('pathAuthFlow'), 'consent flow missing').to.be.a('string').and.not.empty;" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('raw-path-auth exact response status', function () {", + " pm.expect(pm.response.code, 'failed: ' + pm.response.text()).to.equal(401);", + "});" + ] + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Bifrost-Temp-Token", + "value": "{{pathAuthToken}}" + } + ], + "url": "{{baseUrl}}/api/providers/..%2Foauth2%2Fconsent%2Fflows%2F{{pathAuthFlow}}", + "body": { + "mode": "raw", + "raw": "{" + } + } + }, + { + "name": "[PREVIEW] [EXPECT-401] openai raw-path-auth scoped token plugin traversal - #5765", + "protocolProfileBehavior": { + "followRedirects": false, + "disableCookies": true + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "pm.expect(pm.collectionVariables.get('pathAuthToken'), 'run the entire raw-path-auth folder to obtain a valid token').to.be.a('string').and.not.empty;", + "pm.expect(pm.collectionVariables.get('pathAuthFlow'), 'consent flow missing').to.be.a('string').and.not.empty;" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('raw-path-auth exact response status', function () {", + " pm.expect(pm.response.code, 'failed: ' + pm.response.text()).to.equal(401);", + "});" + ] + } + } + ], + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [ + { + "key": "X-Bifrost-Temp-Token", + "value": "{{pathAuthToken}}" + } + ], + "url": "{{baseUrl}}/api/plugins/..%2Foauth2%2Fconsent%2Fflows%2F{{pathAuthFlow}}" + } + } + ] } ] } diff --git a/tests/e2e/api/provider_config/path-auth/config.json b/tests/e2e/api/provider_config/path-auth/config.json new file mode 100644 index 00000000000..fc66c9364d9 --- /dev/null +++ b/tests/e2e/api/provider_config/path-auth/config.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://www.getbifrost.ai/schema", + "client": { + "enable_logging": false, + "mcp_enable_temp_token_auth": true + }, + "governance": { + "auth_config": { + "is_enabled": true, + "admin_username": "path-auth-test", + "admin_password": "local-path-auth-regression-only" + } + }, + "providers": {} +} diff --git a/transports/bifrost-http/handlers/middlewares.go b/transports/bifrost-http/handlers/middlewares.go index ee4b9f18ef1..9eb7d861cb8 100644 --- a/transports/bifrost-http/handlers/middlewares.go +++ b/transports/bifrost-http/handlers/middlewares.go @@ -1059,7 +1059,10 @@ func (m *AuthMiddleware) tryTempTokenOrUnauthorized(ctx *fasthttp.RequestCtx, ne if m.tempTokensService != nil && m.tempTokensEnabled.Load() { token := string(ctx.Request.Header.Peek("X-Bifrost-Temp-Token")) if token != "" { - validated, err := m.tempTokensService.Validate(ctx, token, string(ctx.Method()), string(ctx.Path())) + // Scope authorization must use the same raw path as router dispatch. + // A normalized path can name an allowed flow while the router selects + // a protected management handler through an encoded path parameter. + validated, err := m.tempTokensService.Validate(ctx, token, string(ctx.Method()), string(ctx.Request.URI().PathOriginal())) if err == nil && validated != nil { ctx.SetUserValue(schemas.BifrostContextKeyTempTokenScope, validated.Scope) ctx.SetUserValue(schemas.BifrostContextKeyTempTokenResourceID, validated.ResourceID) @@ -1187,8 +1190,18 @@ func (m *AuthMiddleware) middleware(shouldSkip func(*configstore.AuthConfig, str next(ctx) return } - // Match the whitelist against the path only - url := string(ctx.Path()) + // Match the whitelist against the RAW request path (PathOriginal), not the + // decoded/normalized ctx.Path(). fasthttp/router dispatches routes by matching + // PathOriginal() directly against registered patterns (it never decodes %2F or + // collapses ".." before route selection - see router.Handler), so an encoded + // traversal like "/api/providers/..%2Fskills%2Fserve%2Fx" is ONE opaque segment + // to the router (matching the protected "/api/providers/{provider}" route) but + // decodes+normalizes to "/api/skills/serve/x" via ctx.Path() - a whitelisted + // prefix. Matching on ctx.Path() here let that request sail through unauthenticated + // while the router dispatched it to a protected, parameterized admin handler. + // Using the same raw string the router uses keeps this decision congruent with + // router dispatch for every route, not just the specific one in a given PoC. + url := string(ctx.Request.URI().PathOriginal()) // We skip authorization for the login route if shouldSkip(authConfig, url) { next(ctx) diff --git a/transports/bifrost-http/handlers/middlewares_test.go b/transports/bifrost-http/handlers/middlewares_test.go index e889c76c5b0..599a872b3ac 100644 --- a/transports/bifrost-http/handlers/middlewares_test.go +++ b/transports/bifrost-http/handlers/middlewares_test.go @@ -13,9 +13,13 @@ import ( "time" "github.com/andybalholm/brotli" + "github.com/fasthttp/router" "github.com/klauspost/compress/zstd" "github.com/maximhq/bifrost/core/schemas" "github.com/maximhq/bifrost/framework/configstore" + "github.com/maximhq/bifrost/framework/configstore/tables" + "github.com/maximhq/bifrost/framework/encrypt" + "github.com/maximhq/bifrost/framework/temptoken" "github.com/maximhq/bifrost/framework/tracing" "github.com/maximhq/bifrost/transports/bifrost-http/lib" "github.com/valyala/fasthttp" @@ -732,6 +736,153 @@ func TestAuthMiddleware_SkillsPublicServeManagementSplit(t *testing.T) { }) } +// TestAuthMiddleware_EncodedTraversalDoesNotBypassAuth exercises the same raw-path +// router dispatch as production. Authorization must never use the decoded path +// to whitelist a request dispatched to a protected parameterized handler. +func TestAuthMiddleware_EncodedTraversalDoesNotBypassAuth(t *testing.T) { + am := newTraversalAuthMiddleware() + cases := []struct { + name, method, route, uri string + status int + }{ + {"provider update", "PUT", "/api/providers/{provider}", "/api/providers/..%2Fskills%2Fserve%2Fmalicious", 401}, + {"provider key creation", "POST", "/api/providers/{provider}/keys", "/api/providers/..%2Fskills%2Fserve%2Fmalicious/keys", 401}, + {"provider deletion", "DELETE", "/api/providers/{provider}", "/api/providers/..%2Fskills%2Fserve%2Fmalicious", 401}, + {"plugin dev prefix", "PUT", "/api/plugins/{name}", "/api/plugins/..%2Fdev%2Fmalicious", 401}, + {"lowercase slash", "PUT", "/api/providers/{provider}", "/api/providers/..%2fskills%2fserve%2fmalicious", 401}, + {"encoded dots", "PUT", "/api/providers/{provider}", "/api/providers/%2e%2e%2Fskills%2Fserve%2Fmalicious", 401}, + {"double encoding", "PUT", "/api/providers/{provider}", "/api/providers/%252e%252e%252Fskills%252Fserve%252Fmalicious", 401}, + {"query string", "PUT", "/api/providers/{provider}", "/api/providers/..%2Fskills%2Fserve%2Fmalicious?source=test", 401}, + {"public skills", "GET", "/api/skills/serve/{path:*}", "/api/skills/serve/my-skill.git/info/refs?service=git-upload-pack", 204}, + {"public dev", "GET", "/api/dev/pprof/{profile}", "/api/dev/pprof/goroutine", 204}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assertTraversalAuthRoute(t, am, tc.method, tc.route, tc.uri, "", tc.status) + }) + } +} + +// traversalTokenStore only implements the lookup used by the real token service. +// An embedded interface makes any unexpected store access fail loudly. +type traversalTokenStore struct { + configstore.ConfigStore + row tables.TempToken +} + +func (s *traversalTokenStore) GetTempTokenByHash(_ context.Context, hash string) (*tables.TempToken, error) { + if hash != s.row.TokenHash { + return nil, nil + } + row := s.row + return &row, nil +} + +func newTraversalAuthMiddleware() *AuthMiddleware { + SetLogger(&mockLogger{}) + am := &AuthMiddleware{} + am.UpdateAuthConfig(&configstore.AuthConfig{ + AdminUserName: schemas.NewSecretVar("admin"), + AdminPassword: schemas.NewSecretVar("hashedpassword"), + IsEnabled: true, + }) + return am +} + +// Record router selection separately from the protected handler: a 404 caused +// by a malformed fixture must not be mistaken for successful auth enforcement. +func assertTraversalAuthRoute(t *testing.T, am *AuthMiddleware, method, route, uri, token string, status int) { + t.Helper() + matched, reached := false, false + r := router.New() + protected := am.APIMiddleware()(func(ctx *fasthttp.RequestCtx) { + reached = true + ctx.SetStatusCode(fasthttp.StatusNoContent) + }) + r.Handle(method, route, func(ctx *fasthttp.RequestCtx) { + matched = true + protected(ctx) + }) + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.SetMethod(method) + ctx.Request.SetRequestURI(uri) + if token != "" { + ctx.Request.Header.Set("X-Bifrost-Temp-Token", token) + } + r.Handler(ctx) + if !matched { + t.Fatalf("fixture did not match route %s %s: %q", method, route, uri) + } + if got := ctx.Response.StatusCode(); got != status { + t.Fatalf("%s %s: expected status %d, got %d (handler reached=%v)", method, uri, status, got, reached) + } + if wantReached := status == fasthttp.StatusNoContent; reached != wantReached { + t.Fatalf("%s %s: handler reached=%v, want %v", method, uri, reached, wantReached) + } + if reached && token != "" { + if ctx.UserValue(schemas.BifrostContextKeyTempTokenScope) == nil || ctx.UserValue(schemas.BifrostContextKeyTempTokenResourceID) == nil { + t.Fatal("successful token auth must attach the validated scope and resource ID") + } + } +} + +func TestAuthMiddleware_TempTokenEncodedTraversal(t *testing.T) { + const token = "test-scoped-token" + const flowID = "flow-123" + for _, scope := range []temptoken.Scope{mcpAuthScope, mcpHeadersAuthScope, oauth2ConsentScope} { + t.Run(scope.Name, func(t *testing.T) { + store := &traversalTokenStore{row: tables.TempToken{ + ID: "token-123", TokenHash: encrypt.HashSHA256(token), + Scope: scope.Name, ResourceID: flowID, ExpiresAt: time.Now().Add(time.Hour), + }} + am := newTraversalAuthMiddleware() + am.tempTokensService = temptoken.NewService(store, temptoken.NewRegistry()) + if err := RegisterTempTokenScopes(am.tempTokensService); err != nil { + t.Fatal(err) + } + am.UpdateTempTokenAuthEnabled(true) + for _, allowed := range scope.AllowedRoutes { + path := strings.ReplaceAll(allowed.Path, scope.ResourceIDInPath, flowID) + t.Run(allowed.Method+" "+allowed.Path, func(t *testing.T) { + t.Run("legitimate", func(t *testing.T) { + assertTraversalAuthRoute(t, am, allowed.Method, allowed.Path, path+"?source=test", token, 204) + }) + for _, target := range []string{"/api/providers/{provider}", "/api/plugins/{name}"} { + for _, slash := range []string{"%2F", "%2f"} { + for _, dots := range []string{"..", "%2e%2e"} { + uri := target[:strings.Index(target, "{")] + dots + slash + strings.ReplaceAll(strings.TrimPrefix(path, "/api/"), "/", slash) + t.Run(uri, func(t *testing.T) { + assertTraversalAuthRoute(t, am, allowed.Method, target, uri, token, 401) + }) + } + } + } + t.Run("wrong resource", func(t *testing.T) { + assertTraversalAuthRoute(t, am, allowed.Method, allowed.Path, strings.ReplaceAll(path, flowID, "other-flow"), token, 401) + }) + t.Run("wrong method", func(t *testing.T) { + assertTraversalAuthRoute(t, am, "POST", allowed.Path, path, token, 401) + }) + t.Run("unknown token", func(t *testing.T) { + assertTraversalAuthRoute(t, am, allowed.Method, allowed.Path, path, "unknown-token", 401) + }) + t.Run("expired", func(t *testing.T) { + expiresAt := store.row.ExpiresAt + store.row.ExpiresAt = time.Now().Add(-time.Hour) + defer func() { store.row.ExpiresAt = expiresAt }() + assertTraversalAuthRoute(t, am, allowed.Method, allowed.Path, path, token, 401) + }) + t.Run("disabled", func(t *testing.T) { + am.UpdateTempTokenAuthEnabled(false) + defer am.UpdateTempTokenAuthEnabled(true) + assertTraversalAuthRoute(t, am, allowed.Method, allowed.Path, path, token, 401) + }) + }) + } + }) + } +} + // TestAuthMiddleware_WhitelistedRoutes tests that whitelisted routes bypass auth func TestAuthMiddleware_WhitelistedRoutes(t *testing.T) { SetLogger(&mockLogger{})