Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions apps/sim/lib/core/security/deployment.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Comment on lines +1 to +16
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests not executed — confirmed by PR description

The PR explicitly notes npx vitest run … could not be started due to missing workspace dependencies. The test helpers (signedLegacyToken, signedV2Token) faithfully replicate the production HMAC logic (UTF-8 encoding matches sha256Hex/hmacSha256Hex in @sim/security), so the tests are structurally correct. However, for a security-hardening change the lack of a green CI run is a gap worth closing before merge, especially for the new rejects expired signed tokens and rejects tampered signed token payloads cases.


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)
})
Comment on lines +108 to +113
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No coverage for the future-timestamp path

The expiry suite tests a timestamp 1 ms past 24 h but has no counterpart asserting that a token with timestamp = Date.now() + someOffset is rejected. Given the <= boundary issue in hasValidTimestamp, a test like signedV2Token('dep_test', 'encrypted-password', Date.now() + 60_000) would currently pass validation (returns true) rather than being rejected, confirming the bug in the implementation.


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)
})
})
58 changes: 42 additions & 16 deletions apps/sim/lib/core/security/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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
}
Comment on lines +45 to +50
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 hasValidTimestamp accepts tokens with a future createdAt. When createdAt > Date.now(), the expression Date.now() - createdAt is negative, which always satisfies <= AUTH_TOKEN_TTL_MS (86 400 000), so the check returns true unconditionally. In a browser context the maxAge cookie attribute enforces the wall-clock bound, but any server-side use, non-browser client, or manually constructed cookie bypasses that. Adding a lower bound rejects forward-dated timestamps regardless of context.

Suggested change
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
}
function hasValidTimestamp(timestamp: string): boolean {
const createdAt = Number.parseInt(timestamp, 10)
if (!Number.isFinite(createdAt)) return false
const elapsed = Date.now() - createdAt
return elapsed >= 0 && elapsed <= 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,
Expand All @@ -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
}
Expand Down