Skip to content

Commit 1f06127

Browse files
committed
fix(github): send an explicit User-Agent and stop downgrading a redirected comment POST
`secureGitHubRequest` powers the GitHub comment tool's `directExecution` path, which bypasses the declarative transport. Two behaviors the transport provided did not survive the move. User-Agent: the transport sets `User-Agent: Sim` on every request it formats (`request-transport.ts`), and `secureFetchWithPinnedIP` adds none of its own — it builds the request with raw `node:https` and passes headers through verbatim. Production runs on Bun, whose `node:http` shim injects `user-agent: Bun/x.y.z`, so GitHub does not reject these calls today; the defect is that Sim's deliberate attribution is silently replaced by a runtime version string, and that the tool depends on an undocumented runtime behavior that does not hold under Node, where GitHub answers 403 "Request forbidden by administrative rules". Set in the helper rather than in the tool's header map so every future caller inherits it; a caller-supplied value still wins. Redirect method: the policy was `mode: 'standard'`, under which `resolveRedirectHop` rewrites a 301/302'd POST to a bodyless GET regardless of origin. GitHub answers 301 within api.github.com for a renamed repository, so commenting on a PR there would GET `/pulls/{n}/comments`, receive a JSON array, fail the payload shape check, and report success with no comment created. `legacy` keeps the method and body across that hop. Cross-origin credential stripping is unaffected — the guarded follower strips Authorization, Proxy-Authorization and Cookie whenever `sendCredentialsOnCrossOriginRedirect` is false, in either mode.
1 parent e2b4eae commit 1f06127

2 files changed

Lines changed: 87 additions & 10 deletions

File tree

apps/sim/tools/github/utils.server.test.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ describe('secureGitHubRequest redirects', () => {
8383
expect(hops[0].headers.authorization).toBeUndefined()
8484
})
8585

86-
it('does not replay a comment POST body across an origin boundary', async () => {
86+
it('does not forward the GitHub token when a comment POST crosses an origin boundary', async () => {
8787
const hops: RecordedHop[] = []
8888
const attacker = await startRecordingServer(hops)
8989
const origin = await startServer((req, res) => {
@@ -100,8 +100,32 @@ describe('secureGitHubRequest redirects', () => {
100100

101101
expect(hops).toHaveLength(1)
102102
expect(hops[0].headers.authorization).toBeUndefined()
103-
expect(hops[0].method).toBe('GET')
104-
expect(hops[0].body).toBe('')
103+
expect(hops[0].headers.cookie).toBeUndefined()
104+
})
105+
106+
it('replays a comment POST as a POST across a same-origin renamed-repository 301', async () => {
107+
const hops: RecordedHop[] = []
108+
const origin = await startServer((req, res) => {
109+
if (req.url === '/repos/octo/old/pulls/7/comments') {
110+
req.resume()
111+
res.writeHead(301, { location: '/repos/octo/new/pulls/7/comments' })
112+
res.end()
113+
return
114+
}
115+
record(hops, req, res)
116+
})
117+
118+
await secureGitHubRequest(`${origin}/repos/octo/old/pulls/7/comments`, {
119+
method: 'POST',
120+
headers: { ...GITHUB_HEADERS, 'Content-Type': 'application/json' },
121+
body: '{"body":"Looks good"}',
122+
})
123+
124+
expect(hops).toHaveLength(1)
125+
expect(hops[0].url).toBe('/repos/octo/new/pulls/7/comments')
126+
expect(hops[0].method).toBe('POST')
127+
expect(hops[0].body).toBe('{"body":"Looks good"}')
128+
expect(hops[0].headers.authorization).toBe('Bearer ghp_workspace_token')
105129
})
106130

107131
it('keeps the token on a same-origin redirect, as a renamed repository needs', async () => {
@@ -126,3 +150,32 @@ describe('secureGitHubRequest redirects', () => {
126150
expect(hops[0].headers.authorization).toBe('Bearer ghp_workspace_token')
127151
})
128152
})
153+
154+
describe('secureGitHubRequest User-Agent', () => {
155+
it('sends an explicit Sim User-Agent on the commit lookup and the comment POST', async () => {
156+
const hops: RecordedHop[] = []
157+
const origin = await startRecordingServer(hops)
158+
159+
await secureGitHubRequest(`${origin}/repos/octo/repo/pulls/7`, { headers: GITHUB_HEADERS })
160+
await secureGitHubRequest(`${origin}/repos/octo/repo/pulls/7/comments`, {
161+
method: 'POST',
162+
headers: { ...GITHUB_HEADERS, 'Content-Type': 'application/json' },
163+
body: '{"body":"Looks good"}',
164+
})
165+
166+
expect(hops).toHaveLength(2)
167+
expect(hops[0].headers['user-agent']).toBe('Sim')
168+
expect(hops[1].headers['user-agent']).toBe('Sim')
169+
})
170+
171+
it('leaves a caller-supplied User-Agent untouched', async () => {
172+
const hops: RecordedHop[] = []
173+
const origin = await startRecordingServer(hops)
174+
175+
await secureGitHubRequest(origin, {
176+
headers: { ...GITHUB_HEADERS, 'user-agent': 'Sim-Custom' },
177+
})
178+
179+
expect(hops[0].headers['user-agent']).toBe('Sim-Custom')
180+
})
181+
})

apps/sim/tools/github/utils.server.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,22 @@ export interface SecureGitHubRequestOptions {
1818
signal?: AbortSignal
1919
}
2020

21+
/**
22+
* GitHub's API rejects a request without a User-Agent with 403 "Request forbidden by
23+
* administrative rules". The declarative transport sets `User-Agent: Sim` for every
24+
* tool it formats; a tool on this helper bypasses that, and the guarded fetch builds
25+
* its request with raw `node:https`, which adds no default. Bun's `node:http` shim
26+
* does inject its own `Bun/x.y.z`, so the call happens to work in production today —
27+
* but that silently replaces Sim's attribution and does not hold under Node.
28+
*
29+
* Set here rather than in each caller's header map so every future tool on this
30+
* helper inherits it. A caller that supplies its own User-Agent, in any casing, wins.
31+
*/
32+
function withUserAgent(headers: Record<string, string>): Record<string, string> {
33+
const hasUserAgent = Object.keys(headers).some((name) => name.toLowerCase() === 'user-agent')
34+
return hasUserAgent ? headers : { ...headers, 'User-Agent': 'Sim' }
35+
}
36+
2137
/**
2238
* Executes one DNS-validated, IP-pinned GitHub request for a tool that cannot use
2339
* the declarative transport — a multi-phase tool running under `directExecution`.
@@ -28,11 +44,19 @@ export interface SecureGitHubRequestOptions {
2844
*
2945
* The redirect policy is explicit because omitting it leaves the workspace's GitHub
3046
* token on the request across a cross-origin hop — the transport only strips
31-
* credentials when a policy is present. `standard` also refuses to replay the POST
32-
* a 301/302 downgrades, which is the same non-idempotency reasoning as the missing
33-
* retry loop above. `stripAuthOnRedirect` is deliberately NOT set: it drops the
34-
* token on every hop, and GitHub redirects same-origin for legitimate reasons (a
35-
* renamed repository answers 301 within api.github.com), so an unauthenticated
47+
* credentials when a policy is present. `legacy` is chosen over `standard` for the
48+
* method rules: `standard` rewrites a redirected POST to a bodyless GET on 301/302
49+
* regardless of origin, and GitHub answers 301 within api.github.com for a renamed
50+
* repository, so a comment POST there would be replayed as a GET of the comment
51+
* list — a JSON array that fails the tool's payload shape check and reports success
52+
* with no comment created. `legacy` keeps the method and body across that hop.
53+
*
54+
* Credential stripping is unaffected by the mode: the guarded follower strips
55+
* Authorization, Proxy-Authorization and Cookie on a cross-origin hop whenever
56+
* `sendCredentialsOnCrossOriginRedirect` is false, in either mode.
57+
*
58+
* `stripAuthOnRedirect` is deliberately NOT set: it drops the token on every hop,
59+
* including the legitimate same-origin renamed-repository 301, so an unauthenticated
3660
* replay there would turn a working call into a 401.
3761
*/
3862
export async function secureGitHubRequest(
@@ -46,10 +70,10 @@ export async function secureGitHubRequest(
4670

4771
const response = await secureFetchWithPinnedIP(url, validation.resolvedIP, {
4872
method: options.method ?? 'GET',
49-
headers: options.headers,
73+
headers: withUserAgent(options.headers),
5074
body: options.body,
5175
maxResponseBytes: options.maxResponseBytes ?? GITHUB_MAX_RESPONSE_BYTES,
52-
redirectPolicy: { mode: 'standard', sendCredentialsOnCrossOriginRedirect: false },
76+
redirectPolicy: { mode: 'legacy', sendCredentialsOnCrossOriginRedirect: false },
5377
signal: options.signal,
5478
})
5579

0 commit comments

Comments
 (0)