Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve common password checking #964

Merged
Merged
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
4 changes: 2 additions & 2 deletions app/routes/_auth+/onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx'
import { Spacer } from '#app/components/spacer.tsx'
import { StatusButton } from '#app/components/ui/status-button.tsx'
import {
checkCommonPassword,
checkIsCommonPassword,
requireAnonymous,
sessionKey,
signup,
Expand Down Expand Up @@ -77,7 +77,7 @@ export async function action({ request }: Route.ActionArgs) {
})
return
}
const isCommonPassword = await checkCommonPassword(data.password)
const isCommonPassword = await checkIsCommonPassword(data.password)
if (isCommonPassword) {
ctx.addIssue({
path: ['password'],
Expand Down
4 changes: 2 additions & 2 deletions app/routes/_auth+/reset-password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
import { ErrorList, Field } from '#app/components/forms.tsx'
import { StatusButton } from '#app/components/ui/status-button.tsx'
import {
checkCommonPassword,
checkIsCommonPassword,
requireAnonymous,
resetUserPassword,
} from '#app/utils/auth.server.ts'
Expand Down Expand Up @@ -47,7 +47,7 @@ export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData()
const submission = await parseWithZod(formData, {
schema: ResetPasswordSchema.superRefine(async ({ password }, ctx) => {
const isCommonPassword = await checkCommonPassword(password)
const isCommonPassword = await checkIsCommonPassword(password)
if (isCommonPassword) {
ctx.addIssue({
path: ['password'],
Expand Down
4 changes: 2 additions & 2 deletions app/routes/settings+/profile.password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Button } from '#app/components/ui/button.tsx'
import { Icon } from '#app/components/ui/icon.tsx'
import { StatusButton } from '#app/components/ui/status-button.tsx'
import {
checkCommonPassword,
checkIsCommonPassword,
getPasswordHash,
requireUserId,
verifyUserPassword,
Expand Down Expand Up @@ -74,7 +74,7 @@ export async function action({ request }: Route.ActionArgs) {
message: 'Incorrect password.',
})
}
const isCommonPassword = await checkCommonPassword(newPassword)
const isCommonPassword = await checkIsCommonPassword(newPassword)
if (isCommonPassword) {
ctx.addIssue({
path: ['newPassword'],
Expand Down
4 changes: 2 additions & 2 deletions app/routes/settings+/profile.password_.create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Button } from '#app/components/ui/button.tsx'
import { Icon } from '#app/components/ui/icon.tsx'
import { StatusButton } from '#app/components/ui/status-button.tsx'
import {
checkCommonPassword,
checkIsCommonPassword,
getPasswordHash,
requireUserId,
} from '#app/utils/auth.server.ts'
Expand Down Expand Up @@ -47,7 +47,7 @@ export async function action({ request }: Route.ActionArgs) {
const submission = await parseWithZod(formData, {
async: true,
schema: CreatePasswordForm.superRefine(async ({ password }, ctx) => {
const isCommonPassword = await checkCommonPassword(password)
const isCommonPassword = await checkIsCommonPassword(password)
if (isCommonPassword) {
ctx.addIssue({
path: ['password'],
Expand Down
101 changes: 101 additions & 0 deletions app/utils/auth.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { http, HttpResponse } from 'msw'
import { expect, test, vi } from 'vitest'
import { server } from '#tests/mocks'
import { checkIsCommonPassword, getPasswordHashParts } from './auth.server.ts'

test('checkIsCommonPassword returns true when password is found in breach database', async () => {
const password = 'testpassword'
const [prefix, suffix] = getPasswordHashParts(password)

server.use(
http.get(`https://api.pwnedpasswords.com/range/${prefix}`, () => {
// Include the actual suffix in the response with another realistic suffix
return new HttpResponse(
`1234567890123456789012345678901234A:1\n${suffix}:1234`,
{ status: 200 },
)
}),
)

const result = await checkIsCommonPassword(password)
expect(result).toBe(true)
})

test('checkIsCommonPassword returns false when password is not found in breach database', async () => {
const password = 'sup3r-dup3r-s3cret'
const [prefix] = getPasswordHashParts(password)

server.use(
http.get(`https://api.pwnedpasswords.com/range/${prefix}`, () => {
// Response with realistic suffixes that won't match
return new HttpResponse(
'1234567890123456789012345678901234A:1\n' +
'1234567890123456789012345678901234B:2',
{ status: 200 },
)
}),
)

const result = await checkIsCommonPassword(password)
expect(result).toBe(false)
})

// Error cases
test('checkIsCommonPassword returns false when API returns 500', async () => {
const password = 'testpassword'
const [prefix] = getPasswordHashParts(password)

server.use(
http.get(`https://api.pwnedpasswords.com/range/${prefix}`, () => {
return new HttpResponse(null, { status: 500 })
}),
)

const result = await checkIsCommonPassword(password)
expect(result).toBe(false)
})

test('checkIsCommonPassword times out after 1 second', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn')
server.use(
http.get('https://api.pwnedpasswords.com/range/:prefix', async () => {
const twoSecondDelay = 2000
await new Promise((resolve) => setTimeout(resolve, twoSecondDelay))
return new HttpResponse(
'1234567890123456789012345678901234A:1\n' +
'1234567890123456789012345678901234B:2',
{ status: 200 },
)
}),
)

const result = await checkIsCommonPassword('testpassword')
expect(result).toBe(false)
expect(consoleWarnSpy).toHaveBeenCalledWith('Password check timed out')
consoleWarnSpy.mockRestore()
})

test('checkIsCommonPassword returns false when response has invalid format', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn')
const password = 'testpassword'
const [prefix] = getPasswordHashParts(password)

server.use(
http.get(`https://api.pwnedpasswords.com/range/${prefix}`, () => {
// Create a response that will cause a TypeError when text() is called
const response = new Response()
Object.defineProperty(response, 'text', {
value: () => Promise.resolve(null),
})
return response
}),
)

const result = await checkIsCommonPassword(password)
expect(result).toBe(false)
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Unknown error during password check',
expect.any(TypeError),
)
consoleWarnSpy.mockRestore()
})
35 changes: 21 additions & 14 deletions app/utils/auth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,33 +257,40 @@ export async function verifyUserPassword(
return { id: userWithPassword.id }
}

export async function checkCommonPassword(password: string) {
export function getPasswordHashParts(password: string) {
const hash = crypto
.createHash('sha1')
.update(password, 'utf8')
.digest('hex')
.toUpperCase()
return [hash.slice(0, 5), hash.slice(5)] as const
}

const [prefix, suffix] = [hash.slice(0, 5), hash.slice(5)]

const controller = new AbortController()
export async function checkIsCommonPassword(password: string) {
const [prefix, suffix] = getPasswordHashParts(password)

try {
const timeoutId = setTimeout(() => controller.abort(), 1000)

const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`)

clearTimeout(timeoutId)
const response = await fetch(
`https://api.pwnedpasswords.com/range/${prefix}`,
{
signal: AbortSignal.timeout(1000),
},
)

if (!res.ok) false
if (!response.ok) return false

const data = await res.text()
return data.split('/\r?\n/').some((line) => line.includes(suffix))
const data = await response.text()
return data.split(/\r?\n/).some((line) => {
const [hashSuffix, ignoredPrevalenceCount] = line.split(':')
return hashSuffix === suffix
})
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
if (error instanceof DOMException && error.name === 'TimeoutError') {
console.warn('Password check timed out')
return false
}
console.warn('unknow error during password check', error)

console.warn('Unknown error during password check', error)
return false
}
}
2 changes: 1 addition & 1 deletion tests/mocks/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import closeWithGrace from 'close-with-grace'
import { setupServer } from 'msw/node'
import { handlers as githubHandlers } from './github.ts'
import { handlers as pwnedPasswordApiHandlers } from './pwnedpasswords.ts'
import { handlers as pwnedPasswordApiHandlers } from './pwned-passwords.ts'
import { handlers as resendHandlers } from './resend.ts'
import { handlers as tigrisHandlers } from './tigris.ts'

Expand Down
File renamed without changes.