diff --git a/examples/test-app/pnpm-lock.yaml b/examples/test-app/pnpm-lock.yaml index 5efa6c3bb..6659df777 100644 --- a/examples/test-app/pnpm-lock.yaml +++ b/examples/test-app/pnpm-lock.yaml @@ -10,6 +10,7 @@ overrides: uuid: 14.0.0 ws@8: ^8.20.1 brace-expansion@5: ^5.0.6 + shell-quote: '>=1.8.4' importers: @@ -2594,8 +2595,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.8.3: - resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + shell-quote@1.8.4: + resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} engines: {node: '>= 0.4'} signal-exit@3.0.7: @@ -5751,7 +5752,7 @@ snapshots: react-devtools-core@6.1.5: dependencies: - shell-quote: 1.8.3 + shell-quote: 1.8.4 ws: 7.5.10 transitivePeerDependencies: - bufferutil @@ -5978,7 +5979,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.3: {} + shell-quote@1.8.4: {} signal-exit@3.0.7: {} diff --git a/examples/test-app/pnpm-workspace.yaml b/examples/test-app/pnpm-workspace.yaml index 023c6e043..dace260ed 100644 --- a/examples/test-app/pnpm-workspace.yaml +++ b/examples/test-app/pnpm-workspace.yaml @@ -12,3 +12,4 @@ overrides: uuid: 14.0.0 ws@8: ^8.20.1 brace-expansion@5: ^5.0.6 + shell-quote: '>=1.8.4' diff --git a/src/daemon/http-server.ts b/src/daemon/http-server.ts index 12c54ca7b..d131fa217 100644 --- a/src/daemon/http-server.ts +++ b/src/daemon/http-server.ts @@ -1,6 +1,7 @@ import http, { type IncomingHttpHeaders } from 'node:http'; import fs from 'node:fs'; import { AppError, normalizeError, toAppErrorCode } from '../utils/errors.ts'; +import { timingSafeStringEqual } from '../utils/timing-safe-equal.ts'; import type { JsonRpcId, JsonRpcRequestEnvelope, LeaseBackend } from '../contracts.ts'; import type { DaemonInstallSource, DaemonRequest, DaemonResponse } from './types.ts'; import { normalizeTenantId } from './config.ts'; @@ -818,6 +819,6 @@ function enforceDaemonToken( expectedToken: string | undefined, ): ReturnType | null { if (!expectedToken) return null; - if (requestToken === expectedToken) return null; + if (timingSafeStringEqual(requestToken, expectedToken)) return null; return normalizeError(new AppError('UNAUTHORIZED', 'Invalid token')); } diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index ae729879b..8b12bfbac 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -3,6 +3,7 @@ import { withTargetDeviceResolutionScope, } from '../core/dispatch-resolve.ts'; import { AppError, normalizeError } from '../utils/errors.ts'; +import { timingSafeStringEqual } from '../utils/timing-safe-equal.ts'; import type { DaemonRequest, DaemonResponse } from './types.ts'; import { SessionStore } from './session-store.ts'; import { @@ -83,7 +84,7 @@ export function createRequestHandler( logPath, }, async () => { - if (req.token !== token) { + if (!timingSafeStringEqual(req.token, token)) { return unauthorizedResponse(); } @@ -178,7 +179,7 @@ export function createRequestHandler( ): (req: DaemonRequest) => Promise { return async (req) => { if (!canRunReplayActionInCurrentScope(req, parentScope)) return await handleRequest(req); - if (req.token !== token) { + if (!timingSafeStringEqual(req.token, token)) { return unauthorizedResponse(); } diff --git a/src/daemon/server-lifecycle.ts b/src/daemon/server-lifecycle.ts index 09e74ed73..7950c61f7 100644 --- a/src/daemon/server-lifecycle.ts +++ b/src/daemon/server-lifecycle.ts @@ -49,6 +49,8 @@ export function writeInfo( mode: 0o600, }, ); + // writeFileSync only applies mode on creation; tighten pre-existing files too. + fs.chmodSync(infoPath, 0o600); } export function removeInfo(infoPath: string): void { diff --git a/src/utils/timing-safe-equal.ts b/src/utils/timing-safe-equal.ts new file mode 100644 index 000000000..74363aad3 --- /dev/null +++ b/src/utils/timing-safe-equal.ts @@ -0,0 +1,12 @@ +import crypto from 'node:crypto'; + +/** + * Compares two secret strings in constant time. Hashing both inputs first + * keeps the comparison length-independent, so unequal-length tokens neither + * throw nor leak length via timing. + */ +export function timingSafeStringEqual(a: string, b: string): boolean { + const hashA = crypto.createHash('sha256').update(a).digest(); + const hashB = crypto.createHash('sha256').update(b).digest(); + return crypto.timingSafeEqual(hashA, hashB); +}