Skip to content

Commit 089dcf7

Browse files
Send OAuth token request params in POST body instead of URL
The cli-kit `tokenRequest()` helper was placing all OAuth parameters (`subject_token`, `refresh_token`, `device_code`, `client_id`, etc.) into the URL query string of POST requests to `/oauth/token`. RFC 6749 specifies that these parameters are sent in the request body as `application/x-www-form-urlencoded` data, and putting credentials in URLs causes them to leak into verbose CLI debug output, HTTP access logs, proxies, and anywhere else URLs are recorded. This commit: - Moves the OAuth parameters from the URL query string into the POST request body (form-encoded). - Expands `sanitizeURL` to mask all sensitive OAuth parameter names (`access_token`, `refresh_token`, `id_token`, `subject_token`, `actor_token`, `device_code`, `client_secret`, `code`) so that any URL that ever carries one of these values in the future is still redacted as defence in depth. - Updates tests to assert the new behaviour and adds coverage for every sanitized parameter. Reported through Shopify's bug bounty programme (report #3686978). Requested by Josh Bregar <josh.bregar@shopify.com> Co-authored-by: Josh Bregar <josh.bregar@shopify.com>
1 parent 88f5925 commit 089dcf7

5 files changed

Lines changed: 85 additions & 11 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/cli-kit': patch
3+
---
4+
5+
Send OAuth `/oauth/token` parameters in the POST request body (per RFC 6749) instead of the URL query string, so credentials such as `subject_token`, `refresh_token`, and `device_code` are not written to verbose CLI debug output. Also extends `sanitizeURL` to mask `access_token`, `refresh_token`, `id_token`, `subject_token`, `actor_token`, `device_code`, `client_secret`, and `code` for defence in depth.

packages/cli-kit/src/private/node/api/urls.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,38 @@ describe('sanitizeURL', () => {
4545
// Then
4646
expect(sanitizedUrl).toBe('https://example.com/?subject_token=****&other_param=def456')
4747
})
48+
49+
test.each([
50+
'access_token',
51+
'refresh_token',
52+
'id_token',
53+
'subject_token',
54+
'actor_token',
55+
'device_code',
56+
'client_secret',
57+
'code',
58+
'token',
59+
])('sanitizes %s query parameter', (param) => {
60+
// Given
61+
const url = `https://example.com?${param}=secret-value`
62+
63+
// When
64+
const sanitizedUrl = sanitizeURL(url)
65+
66+
// Then
67+
expect(sanitizedUrl).toBe(`https://example.com/?${param}=****`)
68+
})
69+
70+
test('sanitizes all sensitive query parameters together', () => {
71+
// Given
72+
const url = 'https://example.com?access_token=a&refresh_token=b&device_code=c&subject_token=d&other=keep'
73+
74+
// When
75+
const sanitizedUrl = sanitizeURL(url)
76+
77+
// Then
78+
expect(sanitizedUrl).toBe(
79+
'https://example.com/?access_token=****&refresh_token=****&device_code=****&subject_token=****&other=keep',
80+
)
81+
})
4882
})
Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,30 @@
1+
// Query-string parameter names that may carry sensitive credentials and must
2+
// not be written to logs, verbose debug output, or any other user-visible
3+
// destination. Covers OAuth 2.0 / device-authorization / token-exchange
4+
// parameters.
5+
const SENSITIVE_QUERY_PARAMS = [
6+
'access_token',
7+
'refresh_token',
8+
'id_token',
9+
'subject_token',
10+
'actor_token',
11+
'device_code',
12+
'client_secret',
13+
'code',
14+
'token',
15+
]
16+
117
/**
218
* Removes the sensitive data from the url and outputs them as a string.
319
* @param url - HTTP headers.
420
* @returns A sanitized version of the url as a string.
521
*/
622
export function sanitizeURL(url: string): string {
723
const parsedUrl = new URL(url)
8-
if (parsedUrl.searchParams.has('subject_token')) {
9-
parsedUrl.searchParams.set('subject_token', '****')
10-
}
11-
if (parsedUrl.searchParams.has('token')) {
12-
parsedUrl.searchParams.set('token', '****')
24+
for (const param of SENSITIVE_QUERY_PARAMS) {
25+
if (parsedUrl.searchParams.has(param)) {
26+
parsedUrl.searchParams.set(param, '****')
27+
}
1328
}
1429
return parsedUrl.toString()
1530
}

packages/cli-kit/src/private/node/session/exchange.test.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,10 @@ describe.each(tokenExchangeMethods)(
271271
test(`Executing ${tokenExchangeMethod.name} returns access token and user ID for a valid CLI token`, async () => {
272272
// Given
273273
let capturedUrl = ''
274+
let capturedInit: {method?: string; headers?: Record<string, string>; body?: string} = {}
274275
vi.mocked(shopifyFetch).mockImplementation(async (url, options) => {
275276
capturedUrl = url.toString()
277+
capturedInit = (options ?? {}) as typeof capturedInit
276278
return Promise.resolve(
277279
new Response(
278280
JSON.stringify({
@@ -292,12 +294,21 @@ describe.each(tokenExchangeMethods)(
292294
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe(userId)
293295
await expect(getLastSeenAuthMethod()).resolves.toBe('partners_token')
294296

295-
// Assert token exchange parameters are correct
297+
// Request is sent as POST form-encoded body (not query string), so the
298+
// URL must not contain any OAuth parameters.
296299
const actualUrl = new URL(capturedUrl)
297300
expect(actualUrl).toBeDefined()
298-
expect(actualUrl.href).toContain('https://fqdn.com/oauth/token')
301+
expect(actualUrl.href).toBe('https://fqdn.com/oauth/token')
302+
expect(actualUrl.search).toBe('')
299303

300-
const params = actualUrl.searchParams
304+
expect(capturedInit.method).toBe('POST')
305+
expect(capturedInit.headers).toMatchObject({
306+
'Content-Type': 'application/x-www-form-urlencoded',
307+
})
308+
expect(typeof capturedInit.body).toBe('string')
309+
310+
// Assert token exchange parameters are correct and sent in the body.
311+
const params = new URLSearchParams(capturedInit.body)
301312
expect(params.get('grant_type')).toBe(grantType)
302313
expect(params.get('requested_token_type')).toBe(accessTokenType)
303314
expect(params.get('subject_token_type')).toBe(accessTokenType)

packages/cli-kit/src/private/node/session/exchange.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -226,10 +226,19 @@ async function tokenRequest(
226226
params: Record<string, string>,
227227
): Promise<Result<TokenRequestResult, {error: string; store?: string}>> {
228228
const fqdn = await identityFqdn()
229-
const url = new URL(`https://${fqdn}/oauth/token`)
230-
url.search = new URLSearchParams(Object.entries(params)).toString()
229+
const url = `https://${fqdn}/oauth/token`
231230

232-
const res = await shopifyFetch(url.href, {method: 'POST'})
231+
// Send OAuth parameters in the request body (per RFC 6749) rather than the
232+
// URL query string. This prevents sensitive credentials (subject_token,
233+
// refresh_token, device_code, client_id, etc.) from being written to
234+
// verbose CLI debug output or otherwise leaking through URLs.
235+
const body = new URLSearchParams(Object.entries(params)).toString()
236+
237+
const res = await shopifyFetch(url, {
238+
method: 'POST',
239+
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
240+
body,
241+
})
233242
try {
234243
const responseText = await res.text()
235244

0 commit comments

Comments
 (0)