From f9e565b1781828a9638539ca1dca44adad91f0fe Mon Sep 17 00:00:00 2001 From: 3em0 <59153706+3em0@users.noreply.github.com> Date: Wed, 27 May 2026 13:00:31 +0000 Subject: [PATCH] fix(security): harden deployment auth tokens --- apps/sim/lib/core/security/deployment.test.ts | 120 ++++++++++++++++++ apps/sim/lib/core/security/deployment.ts | 58 ++++++--- 2 files changed, 162 insertions(+), 16 deletions(-) create mode 100644 apps/sim/lib/core/security/deployment.test.ts diff --git a/apps/sim/lib/core/security/deployment.test.ts b/apps/sim/lib/core/security/deployment.test.ts new file mode 100644 index 0000000000..0eeead02a4 --- /dev/null +++ b/apps/sim/lib/core/security/deployment.test.ts @@ -0,0 +1,120 @@ +import { createHash, createHmac } from 'node:crypto' +import { createEnvMock } from '@sim/testing' +import type { NextResponse } from 'next/server' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/config/env', () => + createEnvMock({ + BETTER_AUTH_SECRET: 'deployment-auth-test-secret-32-chars', + }) +) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + isDev: true, +})) + +import { setDeploymentAuthCookie, validateAuthToken } from './deployment' + +const SECRET = 'deployment-auth-test-secret-32-chars' + +function issueCookieToken(encryptedPassword?: string | null): string { + let token = '' + const response = { + cookies: { + set: vi.fn((cookie: { value: string }) => { + token = cookie.value + }), + }, + } as unknown as NextResponse + + setDeploymentAuthCookie(response, 'chat', 'dep_test', 'password', encryptedPassword) + + return token +} + +function forgeUnsignedLegacyToken( + deploymentId: string, + encryptedPassword: string, + timestamp = Date.now() +): string { + const passwordSlot = createHash('sha256').update(encryptedPassword).digest('hex').slice(0, 8) + return Buffer.from(`${deploymentId}:password:${timestamp}:${passwordSlot}`).toString('base64') +} + +function signedLegacyToken( + deploymentId: string, + encryptedPassword: string, + timestamp = Date.now() +): string { + const passwordSlot = createHash('sha256').update(encryptedPassword).digest('hex').slice(0, 8) + const payload = `${deploymentId}:password:${timestamp}:${passwordSlot}` + const signature = createHmac('sha256', SECRET).update(payload, 'utf8').digest('hex') + + return Buffer.from(`${payload}:${signature}`).toString('base64') +} + +function signedV2Token( + deploymentId: string, + encryptedPassword: string, + timestamp = Date.now() +): string { + const payload = `v2:${deploymentId}:password:${timestamp}` + const passwordBinding = createHash('sha256').update(encryptedPassword, 'utf8').digest('hex') + const signature = createHmac('sha256', SECRET) + .update(`${payload}:${passwordBinding}`, 'utf8') + .digest('hex') + + return Buffer.from(`${payload}:${signature}`).toString('base64') +} + +describe('deployment auth tokens', () => { + it('validates signed server-issued tokens', () => { + const token = issueCookieToken('encrypted-password') + + expect(validateAuthToken(token, 'dep_test', 'encrypted-password')).toBe(true) + expect(validateAuthToken(token, 'other-deployment', 'encrypted-password')).toBe(false) + }) + + it('does not expose the password-derived slot in newly issued tokens', () => { + const token = issueCookieToken('encrypted-password') + const decoded = Buffer.from(token, 'base64').toString() + + expect(decoded).toMatch(/^v2:dep_test:password:\d+:[a-f0-9]{64}$/) + expect(decoded).not.toContain( + createHash('sha256').update('encrypted-password').digest('hex').slice(0, 8) + ) + }) + + it('rejects unsigned forged tokens using the old base64 field format', () => { + const token = forgeUnsignedLegacyToken('dep_test', 'encrypted-password') + + expect(validateAuthToken(token, 'dep_test', 'encrypted-password')).toBe(false) + }) + + it('rejects signed tokens after the deployment password changes', () => { + const token = issueCookieToken('encrypted-password') + + expect(validateAuthToken(token, 'dep_test', 'different-encrypted-password')).toBe(false) + }) + + it('rejects tampered signed token payloads', () => { + const token = issueCookieToken('encrypted-password') + const decoded = Buffer.from(token, 'base64').toString() + const tampered = Buffer.from(decoded.replace('dep_test', 'other-deployment')).toString('base64') + + expect(validateAuthToken(tampered, 'other-deployment', 'encrypted-password')).toBe(false) + }) + + it('rejects expired signed tokens', () => { + const expiredTimestamp = Date.now() - 24 * 60 * 60 * 1000 - 1 + const token = signedV2Token('dep_test', 'encrypted-password', expiredTimestamp) + + expect(validateAuthToken(token, 'dep_test', 'encrypted-password')).toBe(false) + }) + + it('accepts signed legacy tokens during the 24 hour cookie window', () => { + const token = signedLegacyToken('dep_test', 'encrypted-password') + + expect(validateAuthToken(token, 'dep_test', 'encrypted-password')).toBe(true) + }) +}) diff --git a/apps/sim/lib/core/security/deployment.ts b/apps/sim/lib/core/security/deployment.ts index 818ff588ed..1315e46b68 100644 --- a/apps/sim/lib/core/security/deployment.ts +++ b/apps/sim/lib/core/security/deployment.ts @@ -11,7 +11,19 @@ import { isDev } from '@/lib/core/config/feature-flags' * endpoints lives in proxy.ts as the single source of truth. */ -function signPayload(payload: string): string { +const AUTH_TOKEN_VERSION = 'v2' +const AUTH_TOKEN_TTL_MS = 24 * 60 * 60 * 1000 + +function passwordBinding(encryptedPassword?: string | null): string { + if (!encryptedPassword) return '' + return sha256Hex(encryptedPassword) +} + +function signPayload(payload: string, encryptedPassword?: string | null): string { + return hmacSha256Hex(`${payload}:${passwordBinding(encryptedPassword)}`, env.BETTER_AUTH_SECRET) +} + +function signLegacyPayload(payload: string): string { return hmacSha256Hex(payload, env.BETTER_AUTH_SECRET) } @@ -25,15 +37,22 @@ function generateAuthToken( type: string, encryptedPassword?: string | null ): string { - const payload = `${deploymentId}:${type}:${Date.now()}:${passwordSlot(encryptedPassword)}` - const sig = signPayload(payload) + const payload = `${AUTH_TOKEN_VERSION}:${deploymentId}:${type}:${Date.now()}` + const sig = signPayload(payload, encryptedPassword) return Buffer.from(`${payload}:${sig}`).toString('base64') } +function hasValidTimestamp(timestamp: string): boolean { + const createdAt = Number.parseInt(timestamp, 10) + if (!Number.isFinite(createdAt)) return false + + return Date.now() - createdAt <= AUTH_TOKEN_TTL_MS +} + /** * Validates an HMAC-signed authentication token for a deployment (chat or form). - * Includes a password-derived slot so changing the deployment password immediately - * invalidates existing sessions. + * The signature is bound to the current encrypted password so changing a + * deployment password immediately invalidates existing sessions. */ export function validateAuthToken( token: string, @@ -48,25 +67,32 @@ export function validateAuthToken( const payload = decoded.slice(0, lastColon) const sig = decoded.slice(lastColon + 1) - const expectedSig = signPayload(payload) - if (!safeCompare(sig, expectedSig)) { - return false + const parts = payload.split(':') + + if (parts[0] === AUTH_TOKEN_VERSION) { + if (parts.length !== 4) return false + + const expectedSig = signPayload(payload, encryptedPassword) + if (!safeCompare(sig, expectedSig)) return false + + const [_version, storedId, _type, timestamp] = parts + if (storedId !== deploymentId) return false + + return hasValidTimestamp(timestamp) } - const parts = payload.split(':') - if (parts.length < 4) return false - const [storedId, _type, timestamp, storedPwSlot] = parts + if (parts.length !== 4) return false + + const expectedSig = signLegacyPayload(payload) + if (!safeCompare(sig, expectedSig)) return false + const [storedId, _type, timestamp, storedPwSlot] = parts if (storedId !== deploymentId) return false const expectedPwSlot = passwordSlot(encryptedPassword) if (storedPwSlot !== expectedPwSlot) return false - const createdAt = Number.parseInt(timestamp) - const expireTime = 24 * 60 * 60 * 1000 - if (Date.now() - createdAt > expireTime) return false - - return true + return hasValidTimestamp(timestamp) } catch (_e) { return false }