Skip to content

Commit 1403919

Browse files
committed
fix(github): stop forwarding the GitHub token across a redirect origin
secureGitHubRequest passed no redirectPolicy, and the transport only strips credential headers when one is present, so an api.github.com redirect to another origin carried the workspace's Authorization: Bearer header to the new host. Adopts the standard policy already used by the internal Google Drive client. stripAuthOnRedirect stays off: GitHub redirects same-origin for legitimate reasons (a renamed repository answers 301), and dropping auth there would turn a working call into a 401.
1 parent cc5a264 commit 1403919

2 files changed

Lines changed: 138 additions & 0 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Pins the redirect contract of the GitHub direct-execution transport: the workspace
3+
* token must never cross an origin boundary, while a legitimate same-origin GitHub
4+
* redirect (a renamed repository) must stay authenticated.
5+
*
6+
* @vitest-environment node
7+
*/
8+
import http from 'node:http'
9+
import type { AddressInfo } from 'node:net'
10+
import { afterEach, describe, expect, it, vi } from 'vitest'
11+
12+
vi.mock('@sim/security/dns', () => ({
13+
resolveHostAddresses: vi.fn(async () => ({ addresses: ['127.0.0.1'] })),
14+
preferIpv4: (addresses: string[]) => addresses[0],
15+
}))
16+
17+
vi.mock('@/lib/core/config/env-flags', () => ({
18+
isHosted: false,
19+
isPrivateDatabaseHostsAllowed: false,
20+
getProxyUrl: () => undefined,
21+
}))
22+
23+
import { secureGitHubRequest } from '@/tools/github/utils.server'
24+
25+
interface RecordedHop {
26+
url: string
27+
method: string
28+
body: string
29+
headers: http.IncomingHttpHeaders
30+
}
31+
32+
const servers: http.Server[] = []
33+
34+
afterEach(() => {
35+
for (const server of servers.splice(0)) server.close()
36+
})
37+
38+
async function startServer(handler: http.RequestListener): Promise<string> {
39+
const server = http.createServer(handler)
40+
servers.push(server)
41+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
42+
return `http://127.0.0.1:${(server.address() as AddressInfo).port}`
43+
}
44+
45+
function record(hops: RecordedHop[], req: http.IncomingMessage, res: http.ServerResponse): void {
46+
let body = ''
47+
req.on('data', (chunk) => {
48+
body += chunk
49+
})
50+
req.on('end', () => {
51+
hops.push({ url: req.url ?? '', method: req.method ?? '', body, headers: req.headers })
52+
res.writeHead(200, { 'Content-Type': 'application/json' })
53+
res.end('{"ok":true}')
54+
})
55+
}
56+
57+
/** Records every request it receives, then answers 200. */
58+
async function startRecordingServer(hops: RecordedHop[]): Promise<string> {
59+
return startServer((req, res) => record(hops, req, res))
60+
}
61+
62+
const GITHUB_HEADERS = {
63+
Accept: 'application/vnd.github.v3+json',
64+
Authorization: 'Bearer ghp_workspace_token',
65+
'X-GitHub-Api-Version': '2022-11-28',
66+
}
67+
68+
describe('secureGitHubRequest redirects', () => {
69+
it('does not forward the GitHub token across an origin boundary', async () => {
70+
const hops: RecordedHop[] = []
71+
const attacker = await startRecordingServer(hops)
72+
const origin = await startServer((req, res) => {
73+
req.resume()
74+
res.writeHead(302, { location: `${attacker}/stolen` })
75+
res.end()
76+
})
77+
78+
const response = await secureGitHubRequest(origin, { headers: GITHUB_HEADERS })
79+
80+
expect(response.status).toBe(200)
81+
expect(hops).toHaveLength(1)
82+
expect(hops[0].url).toBe('/stolen')
83+
expect(hops[0].headers.authorization).toBeUndefined()
84+
})
85+
86+
it('does not replay a comment POST body across an origin boundary', async () => {
87+
const hops: RecordedHop[] = []
88+
const attacker = await startRecordingServer(hops)
89+
const origin = await startServer((req, res) => {
90+
req.resume()
91+
res.writeHead(302, { location: `${attacker}/stolen` })
92+
res.end()
93+
})
94+
95+
await secureGitHubRequest(origin, {
96+
method: 'POST',
97+
headers: { ...GITHUB_HEADERS, 'Content-Type': 'application/json' },
98+
body: '{"body":"Looks good"}',
99+
})
100+
101+
expect(hops).toHaveLength(1)
102+
expect(hops[0].headers.authorization).toBeUndefined()
103+
expect(hops[0].method).toBe('GET')
104+
expect(hops[0].body).toBe('')
105+
})
106+
107+
it('keeps the token on a same-origin redirect, as a renamed repository needs', async () => {
108+
const hops: RecordedHop[] = []
109+
const origin = await startServer((req, res) => {
110+
if (req.url === '/repos/octo/old/pulls/7') {
111+
req.resume()
112+
res.writeHead(301, { location: '/repos/octo/new/pulls/7' })
113+
res.end()
114+
return
115+
}
116+
record(hops, req, res)
117+
})
118+
119+
const response = await secureGitHubRequest(`${origin}/repos/octo/old/pulls/7`, {
120+
headers: GITHUB_HEADERS,
121+
})
122+
123+
expect(response.status).toBe(200)
124+
expect(hops).toHaveLength(1)
125+
expect(hops[0].url).toBe('/repos/octo/new/pulls/7')
126+
expect(hops[0].headers.authorization).toBe('Bearer ghp_workspace_token')
127+
})
128+
})

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ export interface SecureGitHubRequestOptions {
2525
* This deliberately carries no retry loop: the tools on this path declare no
2626
* `request.retry`, so the transport retries them zero times today, and the second
2727
* phase of a comment flow is a non-idempotent POST that must not be replayed.
28+
*
29+
* The redirect policy is explicit because omitting it leaves the workspace's GitHub
30+
* 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
36+
* replay there would turn a working call into a 401.
2837
*/
2938
export async function secureGitHubRequest(
3039
url: string,
@@ -40,6 +49,7 @@ export async function secureGitHubRequest(
4049
headers: options.headers,
4150
body: options.body,
4251
maxResponseBytes: options.maxResponseBytes ?? GITHUB_MAX_RESPONSE_BYTES,
52+
redirectPolicy: { mode: 'standard', sendCredentialsOnCrossOriginRedirect: false },
4353
signal: options.signal,
4454
})
4555

0 commit comments

Comments
 (0)