Skip to content
Closed
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
13 changes: 13 additions & 0 deletions .github/workflows/deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,21 @@ jobs:
node-version: 24

- name: 🎬 Setup ffmpeg
continue-on-error: true
uses: FedericoCarboni/setup-ffmpeg@v3

- name: 🎬 Ensure ffmpeg installed
run: |
if command -v ffmpeg >/dev/null 2>&1; then
ffmpeg -version
exit 0
fi

# Fallback: if the setup action flakes (ex: fetch fails), install via apt.
sudo apt-get -o Acquire::Retries=3 update
sudo apt-get -o Acquire::Retries=3 install -y ffmpeg
ffmpeg -version

- name: 📥 Download deps
uses: bahmutov/npm-install@v1.11.2
with:
Expand Down
6 changes: 3 additions & 3 deletions app/routes/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
reuseUsefulLoaderHeaders,
} from '#app/utils/misc.ts'
import {
applyPasswordSubmissionDelay,
DUMMY_PASSWORD_HASH,
verifyPassword,
} from '#app/utils/password.server.ts'
Expand Down Expand Up @@ -83,6 +84,7 @@ export const meta: MetaFunction<typeof loader, { root: RootLoaderType }> = ({
}

export async function action({ request }: Route.ActionArgs) {
await applyPasswordSubmissionDelay()
const formData = await request.formData()
const loginSession = await getLoginInfoSession(request)

Expand Down Expand Up @@ -141,9 +143,7 @@ export async function action({ request }: Route.ActionArgs) {
loginSession.flashError(
'Invalid email or password. If you do not have a password yet, use "Reset password" to set one.',
)
return redirect(`/login`, {
headers: await loginSession.getHeaders(),
})
return redirect(`/login`, { headers: await loginSession.getHeaders() })
}

const session = await getSession(request)
Expand Down
2 changes: 2 additions & 0 deletions app/routes/me_.password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { HeaderSection } from '#app/components/sections/header-section.tsx'
import { Spacer } from '#app/components/spacer.tsx'
import { ensurePrimary } from '#app/utils/litefs-js.server.ts'
import {
applyPasswordSubmissionDelay,
getPasswordHash,
getPasswordStrengthError,
verifyPassword,
Expand Down Expand Up @@ -36,6 +37,7 @@ export async function loader({ request }: Route.LoaderArgs) {
}

export async function action({ request }: Route.ActionArgs) {
await applyPasswordSubmissionDelay()
const user = await requireUser(request)
const formData = await request.formData()

Expand Down
8 changes: 4 additions & 4 deletions app/routes/reset-password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import { ensurePrimary } from '#app/utils/litefs-js.server.ts'
import { getLoginInfoSession } from '#app/utils/login.server.ts'
import { createAndSendPasswordResetVerificationEmail } from '#app/utils/password-reset.server.ts'
import {
getPasswordStrengthError,
applyPasswordSubmissionDelay,
getPasswordHash,
getPasswordStrengthError,
} from '#app/utils/password.server.ts'
import { prisma } from '#app/utils/prisma.server.ts'
import { getSession, getUser } from '#app/utils/session.server.ts'
Expand Down Expand Up @@ -104,6 +105,7 @@ export async function loader({ request }: Route.LoaderArgs) {
}

export async function action({ request }: Route.ActionArgs) {
await applyPasswordSubmissionDelay()
const loginSession = await getLoginInfoSession(request)
const formData = await request.formData()
const actionId = formData.get('actionId')
Expand Down Expand Up @@ -249,9 +251,7 @@ export async function action({ request }: Route.ActionArgs) {

if (!userRecord) {
loginSession.clean()
loginSession.flashError(
'No account found for that email. Create one instead.',
)
loginSession.flashError('No account found for that email. Create one instead.')
return redirect('/signup', { headers: await loginSession.getHeaders() })
}

Expand Down
10 changes: 4 additions & 6 deletions app/routes/signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
TEAM_SNOWBOARD_MAP,
} from '#app/utils/onboarding.ts'
import {
applyPasswordSubmissionDelay,
getPasswordHash,
getPasswordStrengthError,
} from '#app/utils/password.server.ts'
Expand Down Expand Up @@ -85,6 +86,7 @@ const actionIds = {
}

export async function action({ request }: Route.ActionArgs) {
await applyPasswordSubmissionDelay()
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Delay fires on all action paths, not just the password-submission path.

applyPasswordSubmissionDelay() is called before actionId is read, so the cancel, requestCode, and verifyCode branches all incur a gratuitous 0–250 ms delay. Those paths don't touch a password, so there is no security benefit — only unnecessary UX latency. Only the implicit signUp branch (line 209+) that calls getPasswordHash needs this guard.

🛡️ Proposed fix — move the delay after the early-return branches
 export async function action({ request }: Route.ActionArgs) {
-	await applyPasswordSubmissionDelay()
 	const loginInfoSession = await getLoginInfoSession(request)

 	const requestText = await request.text()
 	const form = new URLSearchParams(requestText)
 	const actionId = form.get('actionId')

 	if (actionId === actionIds.cancel) {
 		// ... (early return)
 	}
 	if (actionId === actionIds.requestCode) {
 		// ... (early return)
 	}
 	if (actionId === actionIds.verifyCode) {
 		// ... (early return)
 	}

+	// Only the signUp path reaches here — guard it with the timing-attack delay.
+	await applyPasswordSubmissionDelay()
 	const signupEmail = loginInfoSession.getSignupEmail()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/routes/signup.tsx` at line 83, The call to applyPasswordSubmissionDelay()
currently runs before actionId is checked and thus adds 0–250ms latency to all
branches (cancel, requestCode, verifyCode) unnecessarily; move the
applyPasswordSubmissionDelay() call so it runs only in the password-handling
path that leads to signUp and calls getPasswordHash. Concretely, remove or
relocate the early applyPasswordSubmissionDelay() invocation and insert it just
before the code that handles the implicit signUp branch (the block that calls
getPasswordHash), leaving the cancel, requestCode, and verifyCode branches
untouched.

const loginInfoSession = await getLoginInfoSession(request)

const requestText = await request.text()
Expand Down Expand Up @@ -290,9 +292,7 @@ export async function action({ request }: Route.ActionArgs) {
loginInfoSession.flashMessage(
'An account already exists for that email. Log in instead (or reset your password).',
)
return redirect('/login', {
headers: await loginInfoSession.getHeaders(),
})
return redirect('/login', { headers: await loginInfoSession.getHeaders() })
}
throw error
}
Expand Down Expand Up @@ -326,9 +326,7 @@ export async function action({ request }: Route.ActionArgs) {
loginInfoSession.flashMessage(
'Your account was created. Please log in to continue.',
)
return redirect('/login', {
headers: await loginInfoSession.getHeaders(),
})
return redirect('/login', { headers: await loginInfoSession.getHeaders() })
}

let clientSession: Awaited<ReturnType<typeof getClientSession>> | null =
Expand Down
79 changes: 79 additions & 0 deletions app/utils/__tests__/password-submission-delay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { expect, test, vi } from 'vitest'
import {
applyPasswordSubmissionDelay,
getPasswordSubmissionDelayMs,
} from '../password.server.ts'

test('getPasswordSubmissionDelayMs clamps non-positive maxMs to 0', () => {
const randomInt = vi.fn(() => 123)

expect(getPasswordSubmissionDelayMs({ maxMs: 0, randomInt })).toBe(0)
expect(getPasswordSubmissionDelayMs({ maxMs: -5, randomInt })).toBe(0)

expect(randomInt).not.toHaveBeenCalled()
})

test('getPasswordSubmissionDelayMs treats non-finite maxMs as 0', () => {
const randomInt = vi.fn(() => 123)

expect(getPasswordSubmissionDelayMs({ maxMs: Number.NaN, randomInt })).toBe(0)
expect(
getPasswordSubmissionDelayMs({
maxMs: Number.POSITIVE_INFINITY,
randomInt,
}),
).toBe(0)

expect(randomInt).not.toHaveBeenCalled()
})

test('getPasswordSubmissionDelayMs caps maxMs to crypto.randomInt range', () => {
const randomInt = vi.fn(() => 0)
void getPasswordSubmissionDelayMs({ maxMs: 2 ** 48, randomInt })
expect(randomInt).toHaveBeenCalledWith(0, 2 ** 48 - 1)
})

test('getPasswordSubmissionDelayMs uses an inclusive upper bound', () => {
const randomInt = vi.fn(() => 42)

expect(getPasswordSubmissionDelayMs({ maxMs: 250, randomInt })).toBe(42)
expect(randomInt).toHaveBeenCalledWith(0, 251)
})

test('applyPasswordSubmissionDelay resolves immediately when maxMs is 0', async () => {
vi.useFakeTimers()
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout')
setTimeoutSpy.mockClear()
try {
const randomInt = vi.fn()
await applyPasswordSubmissionDelay({ maxMs: 0, randomInt })
expect(randomInt).not.toHaveBeenCalled()
expect(setTimeoutSpy).not.toHaveBeenCalled()
} finally {
setTimeoutSpy.mockRestore()
vi.useRealTimers()
}
})

test('applyPasswordSubmissionDelay waits for the sampled delay', async () => {
vi.useFakeTimers()
try {
const randomInt = vi.fn(() => 40)
let resolved = false
const promise = applyPasswordSubmissionDelay({ maxMs: 250, randomInt }).then(
() => {
resolved = true
},
)

await vi.advanceTimersByTimeAsync(39)
expect(resolved).toBe(false)

await vi.advanceTimersByTimeAsync(1)
await promise
expect(resolved).toBe(true)
} finally {
vi.useRealTimers()
}
})

40 changes: 40 additions & 0 deletions app/utils/password.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,46 @@ const PASSWORD_MIN_LENGTH = 8
// NOTE: bcrypt only uses the first 72 bytes of the password.
// Enforcing this avoids giving users a false sense of security.
const PASSWORD_MAX_BYTES = 72
const PASSWORD_SUBMISSION_DELAY_MAX_MS = 250
// `crypto.randomInt` requires (max - min) < 2**48 and both args are safe ints.
// We call `randomInt(0, safeMaxMs + 1)` (exclusive upper bound), so cap `safeMaxMs`
// to keep the exclusive max within range.
const MAX_PASSWORD_SUBMISSION_DELAY_MS = 2 ** 48 - 2
Copy link

Choose a reason for hiding this comment

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

Near-identical constant names risk confusion and future bugs

Low Severity

PASSWORD_SUBMISSION_DELAY_MAX_MS (250, the default delay cap) and MAX_PASSWORD_SUBMISSION_DELAY_MS (2^48−2, the crypto.randomInt safety bound) differ only in word order of "MAX." This makes them extremely easy to confuse, increasing the risk of a future maintainer using the wrong one.

Fix in Cursor Fix in Web


type RandomIntFunction = (min: number, max: number) => number

function normalizeMaxDelayMs(maxMs: number) {
if (!Number.isFinite(maxMs)) return 0
return Math.min(
MAX_PASSWORD_SUBMISSION_DELAY_MS,
Math.max(0, Math.floor(maxMs)),
)
}

/**
* Adds random jitter to password submission handling to make timing attacks
* noisier. Keep the upper bound small to avoid noticeable UX regressions.
*/
export function getPasswordSubmissionDelayMs({
maxMs = PASSWORD_SUBMISSION_DELAY_MAX_MS,
randomInt = crypto.randomInt,
}: {
maxMs?: number
randomInt?: RandomIntFunction
} = {}) {
const safeMaxMs = normalizeMaxDelayMs(maxMs)
// `crypto.randomInt` uses an exclusive upper bound.
return safeMaxMs === 0 ? 0 : randomInt(0, safeMaxMs + 1)
}

export async function applyPasswordSubmissionDelay(options?: {
maxMs?: number
randomInt?: RandomIntFunction
}) {
const delayMs = getPasswordSubmissionDelayMs(options)
if (delayMs <= 0) return
await new Promise<void>((resolve) => setTimeout(resolve, delayMs))
}

export async function getPasswordHash(password: string) {
return bcrypt.hash(password, BCRYPT_COST)
Expand Down
7 changes: 6 additions & 1 deletion mocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,13 @@ const miscHandlers = [
},
),
http.head('https://www.gravatar.com/avatar/:md5Hash', async () => {
// In development, allow real Gravatar lookups when possible.
// In tests/CI (and other non-dev modes), return 404 deterministically to
// avoid flaky external HTTP requests.
if (process.env.NODE_ENV !== 'development') {
return HttpResponse.json(null, { status: 404 })
}
if (await isConnectedToTheInternet()) return passthrough()

return HttpResponse.json(null, { status: 404 })
}),
http.get(/http:\/\/(localhost|127\.0\.0\.1):\d+\/.*/, async () =>
Expand Down
Loading