Skip to content

Commit 832c24c

Browse files
prevent use of common password using the haveibeenpwned password api (#958)
* prevent use of common password using the haveibeenpwned password api * made requested chages 1. Reduced the core logic by extracting it in custom function in auth.server.ts 2. added timeout to the request to skip the check if request takes more than 1s 3. skips the check if request fails 4. added msw mock so that we don't depend upon it in development and testing * Update app/utils/auth.server.ts Co-authored-by: Kent C. Dodds <[email protected]> * requested changes made 1. Added warning in case if the error is not AbortError to get more info about the error 2. updated the msw mock to follow convention of other msw mocks * renamed the common-password to pwnedpasswords --------- Co-authored-by: Kent C. Dodds <[email protected]>
1 parent 468c5e5 commit 832c24c

File tree

5 files changed

+72
-4
lines changed

5 files changed

+72
-4
lines changed

app/routes/_auth+/onboarding.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import { z } from 'zod'
77
import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx'
88
import { Spacer } from '#app/components/spacer.tsx'
99
import { StatusButton } from '#app/components/ui/status-button.tsx'
10-
import { requireAnonymous, sessionKey, signup } from '#app/utils/auth.server.ts'
10+
import {
11+
checkCommonPassword,
12+
requireAnonymous,
13+
sessionKey,
14+
signup,
15+
} from '#app/utils/auth.server.ts'
1116
import { prisma } from '#app/utils/db.server.ts'
1217
import { checkHoneypot } from '#app/utils/honeypot.server.ts'
1318
import { useIsPending } from '#app/utils/misc.tsx'
@@ -72,6 +77,14 @@ export async function action({ request }: Route.ActionArgs) {
7277
})
7378
return
7479
}
80+
const isCommonPassword = await checkCommonPassword(data.password)
81+
if (isCommonPassword) {
82+
ctx.addIssue({
83+
path: ['password'],
84+
code: 'custom',
85+
message: 'Password is too common',
86+
})
87+
}
7588
}).transform(async (data) => {
7689
if (intent !== null) return { ...data, session: null }
7790

app/routes/_auth+/reset-password.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { data, redirect, Form } from 'react-router'
55
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
66
import { ErrorList, Field } from '#app/components/forms.tsx'
77
import { StatusButton } from '#app/components/ui/status-button.tsx'
8-
import { requireAnonymous, resetUserPassword } from '#app/utils/auth.server.ts'
8+
import {
9+
checkCommonPassword,
10+
requireAnonymous,
11+
resetUserPassword,
12+
} from '#app/utils/auth.server.ts'
913
import { useIsPending } from '#app/utils/misc.tsx'
1014
import { PasswordAndConfirmPasswordSchema } from '#app/utils/user-validation.ts'
1115
import { verifySessionStorage } from '#app/utils/verification.server.ts'
@@ -41,8 +45,18 @@ export async function loader({ request }: Route.LoaderArgs) {
4145
export async function action({ request }: Route.ActionArgs) {
4246
const resetPasswordUsername = await requireResetPasswordUsername(request)
4347
const formData = await request.formData()
44-
const submission = parseWithZod(formData, {
45-
schema: ResetPasswordSchema,
48+
const submission = await parseWithZod(formData, {
49+
schema: ResetPasswordSchema.superRefine(async ({ password }, ctx) => {
50+
const isCommonPassword = await checkCommonPassword(password)
51+
if (isCommonPassword) {
52+
ctx.addIssue({
53+
path: ['password'],
54+
code: 'custom',
55+
message: 'Password is too common',
56+
})
57+
}
58+
}),
59+
async: true,
4660
})
4761
if (submission.status !== 'success') {
4862
return data(

app/utils/auth.server.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import crypto from 'node:crypto'
12
import { type Connection, type Password, type User } from '@prisma/client'
23
import bcrypt from 'bcryptjs'
34
import { redirect } from 'react-router'
@@ -255,3 +256,34 @@ export async function verifyUserPassword(
255256

256257
return { id: userWithPassword.id }
257258
}
259+
260+
export async function checkCommonPassword(password: string) {
261+
const hash = crypto
262+
.createHash('sha1')
263+
.update(password, 'utf8')
264+
.digest('hex')
265+
.toUpperCase()
266+
267+
const [prefix, suffix] = [hash.slice(0, 5), hash.slice(5)]
268+
269+
const controller = new AbortController()
270+
271+
try {
272+
const timeoutId = setTimeout(() => controller.abort(), 1000)
273+
274+
const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`)
275+
276+
clearTimeout(timeoutId)
277+
278+
if (!res.ok) false
279+
280+
const data = await res.text()
281+
return data.split('/\r?\n/').some((line) => line.includes(suffix))
282+
} catch (error) {
283+
if (error instanceof Error && error.name === 'AbortError') {
284+
console.warn('Password check timed out')
285+
}
286+
console.warn('unknow error during password check', error)
287+
return false
288+
}
289+
}

tests/mocks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import closeWithGrace from 'close-with-grace'
22
import { setupServer } from 'msw/node'
33
import { handlers as githubHandlers } from './github.ts'
4+
import { handlers as pwnedPasswordApiHandlers } from './pwnedpasswords.ts'
45
import { handlers as resendHandlers } from './resend.ts'
56
import { handlers as tigrisHandlers } from './tigris.ts'
67

78
export const server = setupServer(
89
...resendHandlers,
910
...githubHandlers,
1011
...tigrisHandlers,
12+
...pwnedPasswordApiHandlers,
1113
)
1214

1315
server.listen({

tests/mocks/pwnedpasswords.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { http, HttpResponse } from 'msw'
2+
3+
export const handlers = [
4+
http.get('https://api.pwnedpasswords.com/range/:prefix', () => {
5+
return new HttpResponse('', { status: 200 })
6+
}),
7+
]

0 commit comments

Comments
 (0)