Skip to content

Commit fd5bfcd

Browse files
zrosenbauerclaude
andauthored
security(packages/core): harden auth middleware against insecure transport and injection (#38)
* chore: fix command export default issue * security(packages/core): harden auth middleware against insecure transport and injection Enforce HTTPS on OAuth endpoint URLs per RFC 8252 §8.3, with a loopback exemption for local redirect flows. Escape cmd.exe metacharacters in URLs on Windows to prevent command injection. Remove redundant existsSync check in loadFromPath to eliminate a TOCTOU race condition. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(packages/core): move isSecureAuthUrl before openBrowser Group URL validation exports together for better readability. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5aa9749 commit fd5bfcd

File tree

8 files changed

+189
-5
lines changed

8 files changed

+189
-5
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@kidd-cli/core': patch
3+
---
4+
5+
Harden auth middleware against insecure transport and injection
6+
7+
Enforce HTTPS on OAuth endpoint URLs (authUrl, tokenUrl, deviceAuthUrl) per RFC 8252 §8.3, allowing HTTP only for loopback addresses used during local redirect flows. Resolvers now return null for non-secure URLs.
8+
9+
Escape `cmd.exe` metacharacters (`&`, `|`, `<`, `>`, `^`) in URLs passed to `cmd /c start` on Windows to prevent command injection via query strings.
10+
11+
Remove redundant `existsSync` check in `loadFromPath` to eliminate a TOCTOU race condition, matching the pattern already used in the dotenv resolver.

packages/core/src/lib/store/create-store.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,12 @@ export function createStore<TData = unknown>(options: StoreOptions<TData>): File
4949
* @returns The file content, or null if the file does not exist or cannot be read.
5050
*/
5151
function loadFromPath(filePath: string): string | null {
52-
if (!existsSync(filePath)) {
53-
return null
54-
}
5552
const [error, content] = attempt(() => readFileSync(filePath, 'utf8'))
53+
5654
if (error) {
5755
return null
5856
}
57+
5958
return content
6059
}
6160

packages/core/src/middleware/auth/oauth-server.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,22 @@ vi.mock(import('node:child_process'), () => ({
88
execFile: vi.fn().mockReturnValue({ on: vi.fn() }),
99
}))
1010

11+
vi.mock(import('node:os'), async (importOriginal) => {
12+
const original = await importOriginal()
13+
return {
14+
...original,
15+
platform: vi.fn().mockReturnValue(original.platform()),
16+
}
17+
})
18+
1119
import { execFile } from 'node:child_process'
20+
import { platform } from 'node:os'
1221

1322
import {
1423
createDeferred,
1524
createTimeout,
1625
destroyServer,
26+
isSecureAuthUrl,
1727
openBrowser,
1828
sendSuccessPage,
1929
startLocalServer,
@@ -136,6 +146,36 @@ describe('sendSuccessPage()', () => {
136146
})
137147
})
138148

149+
describe('isSecureAuthUrl()', () => {
150+
it('should accept HTTPS URLs', () => {
151+
expect(isSecureAuthUrl('https://auth.example.com/authorize')).toBeTruthy()
152+
})
153+
154+
it('should reject HTTP URLs on non-loopback hosts', () => {
155+
expect(isSecureAuthUrl('http://auth.example.com/authorize')).toBeFalsy()
156+
})
157+
158+
it('should accept HTTP on 127.0.0.1', () => {
159+
expect(isSecureAuthUrl('http://127.0.0.1:8080/callback')).toBeTruthy()
160+
})
161+
162+
it('should accept HTTP on localhost', () => {
163+
expect(isSecureAuthUrl('http://localhost:3000/callback')).toBeTruthy()
164+
})
165+
166+
it('should accept HTTP on [::1]', () => {
167+
expect(isSecureAuthUrl('http://[::1]:8080/callback')).toBeTruthy()
168+
})
169+
170+
it('should reject non-HTTP schemes', () => {
171+
expect(isSecureAuthUrl('ftp://auth.example.com/authorize')).toBeFalsy()
172+
})
173+
174+
it('should reject invalid URLs', () => {
175+
expect(isSecureAuthUrl('not a url')).toBeFalsy()
176+
})
177+
})
178+
139179
describe('openBrowser()', () => {
140180
afterEach(() => {
141181
vi.clearAllMocks()
@@ -148,6 +188,17 @@ describe('openBrowser()', () => {
148188
const [, args] = vi.mocked(execFile).mock.calls[0]
149189
expect(args).toContain('https://example.com')
150190
})
191+
192+
it('should escape cmd metacharacters in URLs on Windows', () => {
193+
vi.mocked(platform).mockReturnValue('win32')
194+
195+
openBrowser('https://example.com/auth?a=1&b=2')
196+
197+
expect(vi.mocked(execFile)).toHaveBeenCalled()
198+
const [command, args] = vi.mocked(execFile).mock.calls[0]
199+
expect(command).toBe('cmd')
200+
expect(args).toContain('https://example.com/auth?a=1^&b=2')
201+
})
151202
})
152203

153204
describe('startLocalServer()', () => {

packages/core/src/middleware/auth/oauth-server.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,33 @@ export function sendSuccessPage(res: ServerResponse): void {
160160
res.end(CLOSE_PAGE_HTML)
161161
}
162162

163+
/**
164+
* Check whether a URL is safe for use as an OAuth endpoint.
165+
*
166+
* Requires HTTPS for all URLs except loopback addresses, where
167+
* HTTP is permitted per RFC 8252 §8.3 (native app redirect URIs).
168+
*
169+
* @param url - The URL string to validate.
170+
* @returns True when the URL uses HTTPS or HTTP on a loopback address.
171+
*/
172+
export function isSecureAuthUrl(url: string): boolean {
173+
try {
174+
const parsed = new URL(url)
175+
176+
if (parsed.protocol === 'https:') {
177+
return true
178+
}
179+
180+
if (parsed.protocol !== 'http:') {
181+
return false
182+
}
183+
184+
return isLoopbackHost(parsed.hostname)
185+
} catch {
186+
return false
187+
}
188+
}
189+
163190
/**
164191
* Open a URL in the user's default browser using a platform-specific command.
165192
*
@@ -180,7 +207,7 @@ export function openBrowser(url: string): void {
180207

181208
const { command, args } = match(platform())
182209
.with('darwin', () => ({ args: [url], command: 'open' }))
183-
.with('win32', () => ({ args: ['/c', 'start', '', url], command: 'cmd' }))
210+
.with('win32', () => ({ args: ['/c', 'start', '', escapeCmdMeta(url)], command: 'cmd' }))
184211
.otherwise(() => ({ args: [url], command: 'xdg-open' }))
185212

186213
const child = execFile(command, args)
@@ -256,3 +283,32 @@ function isHttpUrl(url: string): boolean {
256283
return false
257284
}
258285
}
286+
287+
/**
288+
* Check whether a hostname is a loopback address.
289+
*
290+
* RFC 8252 §8.3 permits HTTP for loopback interfaces during
291+
* native app authorization flows.
292+
*
293+
* @private
294+
* @param hostname - The hostname to check.
295+
* @returns True when the hostname is a loopback address.
296+
*/
297+
function isLoopbackHost(hostname: string): boolean {
298+
return hostname === '127.0.0.1' || hostname === '[::1]' || hostname === 'localhost'
299+
}
300+
301+
/**
302+
* Escape `cmd.exe` metacharacters in a URL string.
303+
*
304+
* Characters like `&`, `|`, `<`, `>`, and `^` are interpreted as
305+
* command separators or redirectors by `cmd.exe`. Prefixing each
306+
* with `^` neutralises the special meaning.
307+
*
308+
* @private
309+
* @param url - The URL to escape.
310+
* @returns The escaped URL string.
311+
*/
312+
function escapeCmdMeta(url: string): string {
313+
return url.replaceAll(/[&|<>^]/g, '^$&')
314+
}

packages/core/src/middleware/auth/strategies/device-code.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,28 @@ describe('resolveFromDeviceCode()', () => {
5757
vi.clearAllMocks()
5858
})
5959

60+
it('should return null when deviceAuthUrl uses HTTP', async () => {
61+
const prompts = createMockPrompts()
62+
63+
const result = await resolveFromDeviceCode({
64+
...createDefaultOptions(prompts),
65+
deviceAuthUrl: 'http://auth.example.com/device/code',
66+
})
67+
68+
expect(result).toBeNull()
69+
})
70+
71+
it('should return null when tokenUrl uses HTTP', async () => {
72+
const prompts = createMockPrompts()
73+
74+
const result = await resolveFromDeviceCode({
75+
...createDefaultOptions(prompts),
76+
tokenUrl: 'http://auth.example.com/token',
77+
})
78+
79+
expect(result).toBeNull()
80+
})
81+
6082
it('should return null when device auth request fails', async () => {
6183
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network error'))
6284

packages/core/src/middleware/auth/strategies/device-code.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { match } from 'ts-pattern'
1313
import type { Prompts } from '@/context/types.js'
1414

1515
import { createBearerCredential, postFormEncoded } from '../credential.js'
16-
import { openBrowser } from '../oauth-server.js'
16+
import { isSecureAuthUrl, openBrowser } from '../oauth-server.js'
1717
import type { AuthCredential } from '../types.js'
1818

1919
/**
@@ -47,6 +47,14 @@ export async function resolveFromDeviceCode(options: {
4747
readonly prompts: Prompts
4848
readonly openBrowserOnStart?: boolean
4949
}): Promise<AuthCredential | null> {
50+
if (!isSecureAuthUrl(options.deviceAuthUrl)) {
51+
return null
52+
}
53+
54+
if (!isSecureAuthUrl(options.tokenUrl)) {
55+
return null
56+
}
57+
5058
const deadline = Date.now() + options.timeout
5159
const signal = AbortSignal.timeout(options.timeout)
5260

packages/core/src/middleware/auth/strategies/oauth.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,34 @@ describe('resolveFromOAuth()', () => {
114114
vi.restoreAllMocks()
115115
})
116116

117+
it('should return null when authUrl uses HTTP', async () => {
118+
const result = await resolveFromOAuth({
119+
authUrl: 'http://auth.example.com/authorize',
120+
callbackPath: '/callback',
121+
clientId: 'test-client',
122+
port: 0,
123+
scopes: [],
124+
timeout: 5000,
125+
tokenUrl: 'https://auth.example.com/token',
126+
})
127+
128+
expect(result).toBeNull()
129+
})
130+
131+
it('should return null when tokenUrl uses HTTP', async () => {
132+
const result = await resolveFromOAuth({
133+
authUrl: 'https://auth.example.com/authorize',
134+
callbackPath: '/callback',
135+
clientId: 'test-client',
136+
port: 0,
137+
scopes: [],
138+
timeout: 5000,
139+
tokenUrl: 'http://auth.example.com/token',
140+
})
141+
142+
expect(result).toBeNull()
143+
})
144+
117145
it('should return null when the timeout fires before a code arrives', async () => {
118146
const fetchSpy = vi.spyOn(globalThis, 'fetch')
119147

packages/core/src/middleware/auth/strategies/oauth.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
createDeferred,
1717
createTimeout,
1818
destroyServer,
19+
isSecureAuthUrl,
1920
openBrowser,
2021
sendSuccessPage,
2122
startLocalServer,
@@ -44,6 +45,14 @@ export async function resolveFromOAuth(options: {
4445
readonly callbackPath: string
4546
readonly timeout: number
4647
}): Promise<AuthCredential | null> {
48+
if (!isSecureAuthUrl(options.authUrl)) {
49+
return null
50+
}
51+
52+
if (!isSecureAuthUrl(options.tokenUrl)) {
53+
return null
54+
}
55+
4756
const codeVerifier = generateCodeVerifier()
4857
const codeChallenge = deriveCodeChallenge(codeVerifier)
4958
const state = randomBytes(32).toString('hex')

0 commit comments

Comments
 (0)