Repository navigation
fix(scanner): allow Codex and Grok sign-in bearers to their own APIs #2159
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
bc1616c
fix: allow Codex and Grok sign-in bearers to their own APIs
luckyPipewrench 4e1f44a
test: cover sign-in bearer scheme, header shape and request-header scan
luckyPipewrench efa6907
fix(scanner): bind the sign-in claims to the matched token
luckyPipewrench 83d4c4d
chore(release): register the sign-in source tags in the version inven…
luckyPipewrench 7f15111
Merge remote-tracking branch 'origin/main' into fix/provider-jwt-audi…
luckyPipewrench 68a9218
test(proxy): keep decoded foreign JWT findings beside a sign-in bearer
luckyPipewrench e809530
test(proxy): cover Grok routes, client ID, path and the AWS finding i…
luckyPipewrench bef22d9
test(proxy): require the recorded sign-in allowance and cover Grok cl…
luckyPipewrench File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| // Copyright 2026 Pipelock contributors | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package proxy | ||
|
|
||
| import ( | ||
| "encoding/base64" | ||
| "encoding/json" | ||
| "fmt" | ||
| "net/http" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/luckyPipewrench/pipelock/internal/config" | ||
| "github.com/luckyPipewrench/pipelock/internal/scanner" | ||
| ) | ||
|
|
||
| func signInTestJWT(t *testing.T, claims map[string]any) string { | ||
| t.Helper() | ||
| payload, err := json.Marshal(claims) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| return base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + "." + | ||
| base64.RawURLEncoding.EncodeToString(payload) + "." + strings.Repeat("a", 24) | ||
| } | ||
|
|
||
| // The sign-in Bearer must pass the whole request-header scan, including the | ||
| // joined-header copy, which is where a header set with more than one value is | ||
| // decided. Scanner-level tests do not reach that path. | ||
| func TestSignInBearer_RequestHeaderScan(t *testing.T) { | ||
| codex := signInTestJWT(t, map[string]any{"iss": "https://auth.openai.com", "client_id": "app_EMoamEEZ73f0CkXaXp7hrann"}) | ||
| other := signInTestJWT(t, map[string]any{"iss": "https://auth.vendor.example", "client_id": "app_other"}) | ||
| wrongClient := signInTestJWT(t, map[string]any{"iss": "https://auth.openai.com", "client_id": "app_other"}) | ||
| grok := signInTestJWT(t, map[string]any{"iss": "https://auth.x.ai", "client_id": "b1a00492-073a-47ea-816f-4c329264a828"}) | ||
| grokWrongClient := signInTestJWT(t, map[string]any{"iss": "https://auth.x.ai", "client_id": "app_other"}) | ||
| grokWrongIssuer := signInTestJWT(t, map[string]any{"iss": "https://auth.openai.com", "client_id": "b1a00492-073a-47ea-816f-4c329264a828"}) | ||
| const target = "https://chatgpt.com/backend-api/codex/responses" | ||
|
|
||
| for _, mode := range []string{config.HeaderModeSensitive, config.HeaderModeAll} { | ||
| cfg := config.Defaults() | ||
| cfg.Internal = nil | ||
| cfg.RequestBodyScanning.ScanHeaders = true | ||
| cfg.RequestBodyScanning.HeaderMode = mode | ||
| sc := scanner.MustNew(cfg) | ||
| t.Cleanup(sc.Close) | ||
|
|
||
| for _, tc := range []struct { | ||
| name string | ||
| headers http.Header | ||
| target string | ||
| clean bool | ||
| allOnly bool // the carrier header is outside the sensitive list | ||
| want string // this finding itself must remain, not just another block | ||
| }{ | ||
| {"codex client headers", http.Header{ | ||
| "Authorization": {"Bearer " + codex}, "User-Agent": {"codex_cli_rs/0.167.0"}, "Chatgpt-Account-Id": {"acct-1"}, | ||
| }, target, true, false, ""}, | ||
| {"authorization only", http.Header{"Authorization": {"Bearer " + codex}}, target, true, false, ""}, | ||
| {"same token copied to another header", http.Header{ | ||
| "Authorization": {"Bearer " + codex}, "X-Copy": {codex}, "User-Agent": {"codex_cli_rs/0.167.0"}, | ||
| }, target, false, true, ""}, | ||
| {"grok at api.x.ai", http.Header{"Authorization": {"Bearer " + grok}, "User-Agent": {"x"}}, "https://api.x.ai/v1/chat/completions", true, false, ""}, | ||
| {"grok at the cli chat proxy", http.Header{"Authorization": {"Bearer " + grok}, "User-Agent": {"x"}}, "https://cli-chat-proxy.grok.com/v1/responses", true, false, ""}, | ||
| {"grok client id mismatch at api.x.ai", http.Header{"Authorization": {"Bearer " + grokWrongClient}, "User-Agent": {"x"}}, "https://api.x.ai/v1/chat/completions", false, false, "JWT Token"}, | ||
| {"grok issuer mismatch at the cli chat proxy", http.Header{"Authorization": {"Bearer " + grokWrongIssuer}, "User-Agent": {"x"}}, "https://cli-chat-proxy.grok.com/v1/responses", false, false, "JWT Token"}, | ||
| {"codex issuer with another client id", http.Header{"Authorization": {"Bearer " + wrongClient}, "User-Agent": {"x"}}, target, false, false, "JWT Token"}, | ||
| {"chatgpt.com outside the backend API", http.Header{"Authorization": {"Bearer " + codex}, "User-Agent": {"x"}}, "https://chatgpt.com/api/other", false, false, "JWT Token"}, | ||
| {"foreign token", http.Header{"Authorization": {"Bearer " + other}, "User-Agent": {"x"}}, target, false, false, ""}, | ||
| {"other host", http.Header{"Authorization": {"Bearer " + codex}, "User-Agent": {"x"}}, "https://api.vendor.example/backend-api/codex", false, false, ""}, | ||
| {"wss target", http.Header{"Authorization": {"Bearer " + codex}, "User-Agent": {"x"}}, "wss://chatgpt.com/backend-api/codex/responses", false, false, ""}, | ||
| {"percent-encoded foreign token ahead of the sign-in token", http.Header{ | ||
| "Authorization": {"Bearer " + percentEncodeAll(other) + codex}, "User-Agent": {"x"}, | ||
| }, target, false, false, "JWT Token"}, | ||
| {"base64 foreign token ahead of the sign-in token", http.Header{ | ||
| "Authorization": {"Bearer " + base64.StdEncoding.EncodeToString([]byte(other+"!")) + codex}, "User-Agent": {"x"}, | ||
| }, target, false, false, "JWT Token"}, | ||
| {"unrelated secret beside token", http.Header{ | ||
| "Authorization": {"Bearer " + codex + " AKIA" + strings.Repeat("A", 16)}, "User-Agent": {"x"}, | ||
| }, target, false, false, "AWS Access ID"}, | ||
| {"secret split across headers", http.Header{ | ||
| "Authorization": {"Bearer " + codex + "AKIA" + strings.Repeat("A", 8)}, "X-Split": {"C" + strings.Repeat("A", 7)}, | ||
| }, target, false, true, ""}, | ||
| } { | ||
| if tc.allOnly && mode != config.HeaderModeAll { | ||
| continue | ||
| } | ||
| t.Run(mode+"/"+tc.name, func(t *testing.T) { | ||
| var allows []scanner.CredentialAudienceAllow | ||
| result := scanRequestHeadersForTargetWithAudience(t.Context(), tc.headers, cfg, sc, tc.target, nil, | ||
| func(allow scanner.CredentialAudienceAllow) error { allows = append(allows, allow); return nil }) | ||
| clean := result == nil || result.Clean | ||
| if clean != tc.clean { | ||
| t.Fatalf("clean=%t want %t result=%#v allows=%#v", clean, tc.clean, result, allows) | ||
| } | ||
| // A clean result alone would also pass if the scan were skipped; the | ||
| // sign-in allowance for this destination must have been recorded. | ||
| if tc.clean { | ||
| host := strings.SplitN(strings.TrimPrefix(tc.target, "https://"), "/", 2)[0] | ||
| recorded := false | ||
| for _, a := range allows { | ||
| recorded = recorded || (a.PatternName == "JWT Token" && a.Destination == host) | ||
| } | ||
| if !recorded { | ||
| t.Fatalf("no JWT Token allowance recorded for %s: %#v", host, allows) | ||
| } | ||
| } | ||
| if tc.want != "" { | ||
| found := false | ||
| for _, m := range result.DLPMatches { | ||
| found = found || m.PatternName == tc.want | ||
| } | ||
| if !found { | ||
| t.Fatalf("%s finding was dropped: %#v", tc.want, result.DLPMatches) | ||
| } | ||
| } | ||
| }) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // percentEncodeAll encodes every byte, so the value carries no plaintext token. | ||
| func percentEncodeAll(v string) string { | ||
| var b strings.Builder | ||
| for i := 0; i < len(v); i++ { | ||
| fmt.Fprintf(&b, "%%%02X", v[i]) | ||
| } | ||
| return b.String() | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| // Copyright 2026 Pipelock contributors | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package scanner | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "net/url" | ||
| "strings" | ||
| ) | ||
|
|
||
| // signInBearerRoute is one provider API that accepts a sign-in access token, | ||
| // a JWT, as the Authorization bearer. | ||
| type signInBearerRoute struct { | ||
| host string | ||
| prefix string | ||
| issuer string | ||
| clientID string | ||
| } | ||
|
|
||
| // signInBearerRoutes are the coding clients that sign in with OAuth and send | ||
| // the resulting JWT access token to their vendor's own API. Sources: | ||
| // - Codex CLI signed in with ChatGPT, openai/codex at rust-v0.167.0: | ||
| // codex-rs/login/src/server.rs DEFAULT_ISSUER, codex-rs/login/src/auth/manager.rs | ||
| // CLIENT_ID, and the https://chatgpt.com/backend-api/ base URL in | ||
| // codex-rs/cloud-config/src/bundle_loader.rs. | ||
| // https://github.com/openai/codex | ||
| // - The xAI Grok CLI OAuth client, router-for-me/CLIProxyAPI at v8.0.51: | ||
| // internal/auth/xai/types.go Issuer, ClientID, DefaultAPIBaseURL and | ||
| // CLIChatProxyBaseURL. | ||
| // https://github.com/router-for-me/CLIProxyAPI | ||
| // | ||
| // The proxy cannot verify the token's signature. A token earns a route only | ||
| // when its own iss and client_id claims name that provider and client, so a | ||
| // JWT from any other issuer or client keeps the block, and a forged token can | ||
| // reach only the issuer's own API host. The registry bearer rule makes the | ||
| // same trade. | ||
| var signInBearerRoutes = []signInBearerRoute{ | ||
| {host: "chatgpt.com", prefix: "/backend-api", issuer: "https://auth.openai.com", clientID: "app_EMoamEEZ73f0CkXaXp7hrann"}, | ||
| {host: "api.x.ai", prefix: "/v1", issuer: "https://auth.x.ai", clientID: "b1a00492-073a-47ea-816f-4c329264a828"}, | ||
| {host: "cli-chat-proxy.grok.com", prefix: "/v1", issuer: "https://auth.x.ai", clientID: "b1a00492-073a-47ea-816f-4c329264a828"}, | ||
| } | ||
|
|
||
| // signInBearerAllowed accepts a JWT Token match only as the whole | ||
| // Authorization Bearer value, over https, at an exact route host and under | ||
| // its path prefix, when the token's iss and client_id equal that route's. | ||
| func signInBearerAllowed(candidate credentialAudienceCandidate, host, target, surface string) bool { | ||
| // The claims are read from the header value, so they describe the matched | ||
| // token only when the match is in that raw value. The whitespace view only | ||
| // joins fields, and the single-token shape below leaves nothing to join. A | ||
| // token visible only after decoding (percent, base64, hex) could sit beside | ||
| // a genuine sign-in token and borrow its claims. | ||
| if candidate.patternName != "JWT Token" || surface != CredentialAudienceAuthorizationHeaderSurface || | ||
| (candidate.encoded != "" && candidate.encoded != "whitespace") { | ||
| return false | ||
| } | ||
| parsed, err := url.Parse(target) | ||
| if err != nil || !strings.EqualFold(parsed.Scheme, "https") { | ||
| return false | ||
| } | ||
| path := parsed.EscapedPath() | ||
| if strings.ContainsAny(path, "%\\") || strings.Contains(path, "//") { | ||
| return false | ||
| } | ||
| for _, segment := range strings.Split(path, "/") { | ||
| if segment == "." || segment == ".." { | ||
| return false | ||
| } | ||
| } | ||
| fields := strings.Fields(candidate.headerValue) | ||
| if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") { | ||
| return false | ||
| } | ||
| parts := strings.Split(fields[1], ".") | ||
| if len(parts) != 3 { | ||
| return false | ||
| } | ||
| var claims map[string]json.RawMessage | ||
| if !decodeJWTSegment(parts[1], 0, &claims) { | ||
| return false | ||
| } | ||
| var issuer, clientID string | ||
| if !jsonField(claims, "iss", &issuer) || !jsonField(claims, "client_id", &clientID) { | ||
| return false | ||
| } | ||
| for _, route := range signInBearerRoutes { | ||
| if host == route.host && strings.HasPrefix(path, route.prefix+"/") && | ||
| issuer == route.issuer && clientID == route.clientID { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
luckyPipewrench marked this conversation as resolved.
Resolved
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.