Skip to content

Commit f9e565b

Browse files
committed
fix(security): harden deployment auth tokens
1 parent fd19470 commit f9e565b

2 files changed

Lines changed: 162 additions & 16 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { createHash, createHmac } from 'node:crypto'
2+
import { createEnvMock } from '@sim/testing'
3+
import type { NextResponse } from 'next/server'
4+
import { describe, expect, it, vi } from 'vitest'
5+
6+
vi.mock('@/lib/core/config/env', () =>
7+
createEnvMock({
8+
BETTER_AUTH_SECRET: 'deployment-auth-test-secret-32-chars',
9+
})
10+
)
11+
12+
vi.mock('@/lib/core/config/feature-flags', () => ({
13+
isDev: true,
14+
}))
15+
16+
import { setDeploymentAuthCookie, validateAuthToken } from './deployment'
17+
18+
const SECRET = 'deployment-auth-test-secret-32-chars'
19+
20+
function issueCookieToken(encryptedPassword?: string | null): string {
21+
let token = ''
22+
const response = {
23+
cookies: {
24+
set: vi.fn((cookie: { value: string }) => {
25+
token = cookie.value
26+
}),
27+
},
28+
} as unknown as NextResponse
29+
30+
setDeploymentAuthCookie(response, 'chat', 'dep_test', 'password', encryptedPassword)
31+
32+
return token
33+
}
34+
35+
function forgeUnsignedLegacyToken(
36+
deploymentId: string,
37+
encryptedPassword: string,
38+
timestamp = Date.now()
39+
): string {
40+
const passwordSlot = createHash('sha256').update(encryptedPassword).digest('hex').slice(0, 8)
41+
return Buffer.from(`${deploymentId}:password:${timestamp}:${passwordSlot}`).toString('base64')
42+
}
43+
44+
function signedLegacyToken(
45+
deploymentId: string,
46+
encryptedPassword: string,
47+
timestamp = Date.now()
48+
): string {
49+
const passwordSlot = createHash('sha256').update(encryptedPassword).digest('hex').slice(0, 8)
50+
const payload = `${deploymentId}:password:${timestamp}:${passwordSlot}`
51+
const signature = createHmac('sha256', SECRET).update(payload, 'utf8').digest('hex')
52+
53+
return Buffer.from(`${payload}:${signature}`).toString('base64')
54+
}
55+
56+
function signedV2Token(
57+
deploymentId: string,
58+
encryptedPassword: string,
59+
timestamp = Date.now()
60+
): string {
61+
const payload = `v2:${deploymentId}:password:${timestamp}`
62+
const passwordBinding = createHash('sha256').update(encryptedPassword, 'utf8').digest('hex')
63+
const signature = createHmac('sha256', SECRET)
64+
.update(`${payload}:${passwordBinding}`, 'utf8')
65+
.digest('hex')
66+
67+
return Buffer.from(`${payload}:${signature}`).toString('base64')
68+
}
69+
70+
describe('deployment auth tokens', () => {
71+
it('validates signed server-issued tokens', () => {
72+
const token = issueCookieToken('encrypted-password')
73+
74+
expect(validateAuthToken(token, 'dep_test', 'encrypted-password')).toBe(true)
75+
expect(validateAuthToken(token, 'other-deployment', 'encrypted-password')).toBe(false)
76+
})
77+
78+
it('does not expose the password-derived slot in newly issued tokens', () => {
79+
const token = issueCookieToken('encrypted-password')
80+
const decoded = Buffer.from(token, 'base64').toString()
81+
82+
expect(decoded).toMatch(/^v2:dep_test:password:\d+:[a-f0-9]{64}$/)
83+
expect(decoded).not.toContain(
84+
createHash('sha256').update('encrypted-password').digest('hex').slice(0, 8)
85+
)
86+
})
87+
88+
it('rejects unsigned forged tokens using the old base64 field format', () => {
89+
const token = forgeUnsignedLegacyToken('dep_test', 'encrypted-password')
90+
91+
expect(validateAuthToken(token, 'dep_test', 'encrypted-password')).toBe(false)
92+
})
93+
94+
it('rejects signed tokens after the deployment password changes', () => {
95+
const token = issueCookieToken('encrypted-password')
96+
97+
expect(validateAuthToken(token, 'dep_test', 'different-encrypted-password')).toBe(false)
98+
})
99+
100+
it('rejects tampered signed token payloads', () => {
101+
const token = issueCookieToken('encrypted-password')
102+
const decoded = Buffer.from(token, 'base64').toString()
103+
const tampered = Buffer.from(decoded.replace('dep_test', 'other-deployment')).toString('base64')
104+
105+
expect(validateAuthToken(tampered, 'other-deployment', 'encrypted-password')).toBe(false)
106+
})
107+
108+
it('rejects expired signed tokens', () => {
109+
const expiredTimestamp = Date.now() - 24 * 60 * 60 * 1000 - 1
110+
const token = signedV2Token('dep_test', 'encrypted-password', expiredTimestamp)
111+
112+
expect(validateAuthToken(token, 'dep_test', 'encrypted-password')).toBe(false)
113+
})
114+
115+
it('accepts signed legacy tokens during the 24 hour cookie window', () => {
116+
const token = signedLegacyToken('dep_test', 'encrypted-password')
117+
118+
expect(validateAuthToken(token, 'dep_test', 'encrypted-password')).toBe(true)
119+
})
120+
})

apps/sim/lib/core/security/deployment.ts

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,19 @@ import { isDev } from '@/lib/core/config/feature-flags'
1111
* endpoints lives in proxy.ts as the single source of truth.
1212
*/
1313

14-
function signPayload(payload: string): string {
14+
const AUTH_TOKEN_VERSION = 'v2'
15+
const AUTH_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
16+
17+
function passwordBinding(encryptedPassword?: string | null): string {
18+
if (!encryptedPassword) return ''
19+
return sha256Hex(encryptedPassword)
20+
}
21+
22+
function signPayload(payload: string, encryptedPassword?: string | null): string {
23+
return hmacSha256Hex(`${payload}:${passwordBinding(encryptedPassword)}`, env.BETTER_AUTH_SECRET)
24+
}
25+
26+
function signLegacyPayload(payload: string): string {
1527
return hmacSha256Hex(payload, env.BETTER_AUTH_SECRET)
1628
}
1729

@@ -25,15 +37,22 @@ function generateAuthToken(
2537
type: string,
2638
encryptedPassword?: string | null
2739
): string {
28-
const payload = `${deploymentId}:${type}:${Date.now()}:${passwordSlot(encryptedPassword)}`
29-
const sig = signPayload(payload)
40+
const payload = `${AUTH_TOKEN_VERSION}:${deploymentId}:${type}:${Date.now()}`
41+
const sig = signPayload(payload, encryptedPassword)
3042
return Buffer.from(`${payload}:${sig}`).toString('base64')
3143
}
3244

45+
function hasValidTimestamp(timestamp: string): boolean {
46+
const createdAt = Number.parseInt(timestamp, 10)
47+
if (!Number.isFinite(createdAt)) return false
48+
49+
return Date.now() - createdAt <= AUTH_TOKEN_TTL_MS
50+
}
51+
3352
/**
3453
* Validates an HMAC-signed authentication token for a deployment (chat or form).
35-
* Includes a password-derived slot so changing the deployment password immediately
36-
* invalidates existing sessions.
54+
* The signature is bound to the current encrypted password so changing a
55+
* deployment password immediately invalidates existing sessions.
3756
*/
3857
export function validateAuthToken(
3958
token: string,
@@ -48,25 +67,32 @@ export function validateAuthToken(
4867
const payload = decoded.slice(0, lastColon)
4968
const sig = decoded.slice(lastColon + 1)
5069

51-
const expectedSig = signPayload(payload)
52-
if (!safeCompare(sig, expectedSig)) {
53-
return false
70+
const parts = payload.split(':')
71+
72+
if (parts[0] === AUTH_TOKEN_VERSION) {
73+
if (parts.length !== 4) return false
74+
75+
const expectedSig = signPayload(payload, encryptedPassword)
76+
if (!safeCompare(sig, expectedSig)) return false
77+
78+
const [_version, storedId, _type, timestamp] = parts
79+
if (storedId !== deploymentId) return false
80+
81+
return hasValidTimestamp(timestamp)
5482
}
5583

56-
const parts = payload.split(':')
57-
if (parts.length < 4) return false
58-
const [storedId, _type, timestamp, storedPwSlot] = parts
84+
if (parts.length !== 4) return false
85+
86+
const expectedSig = signLegacyPayload(payload)
87+
if (!safeCompare(sig, expectedSig)) return false
5988

89+
const [storedId, _type, timestamp, storedPwSlot] = parts
6090
if (storedId !== deploymentId) return false
6191

6292
const expectedPwSlot = passwordSlot(encryptedPassword)
6393
if (storedPwSlot !== expectedPwSlot) return false
6494

65-
const createdAt = Number.parseInt(timestamp)
66-
const expireTime = 24 * 60 * 60 * 1000
67-
if (Date.now() - createdAt > expireTime) return false
68-
69-
return true
95+
return hasValidTimestamp(timestamp)
7096
} catch (_e) {
7197
return false
7298
}

0 commit comments

Comments
 (0)