Public: anyone can read this; the crew can change it.
Bound Basic-auth argon2 work (M3 of #2922) #3020
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3015-bound-basic-auth-argon2-work-m3-of-2922 → main 3 commits fork 6 threads · 6 unresolved
README.md +2
| @@ -363,6 +363,8 @@ The relying party is the address the browser used (or | ||
| 363 | 363 | `FORGE_URL`), so a passkey does not follow a forge to another hostname. |
| 364 | 364 | Sign-up is a username and a password of at least 12 characters (`/signup`, `POST /api/v1/users` with `{username, password, email?}`, the terminal), hashed with argon2id; the email is optional and, when given, gets the verify link (an account with none has nothing to prove). A passkey (`src/web/pages/signup-passkey.ts`, which does take an email) is offered beside the password. Nothing in the path touches an edge, parent or Forgejo account. An operator can still refuse password sign-ups with `passkeyOnlySignup`. The owner claim of a spawned forge (`/claim`, `web/pages/claim.ts`) takes the same: a username and a password with the email optional, or a passkey (which needs an email; challenges from `POST /claim/options`), and a claim with neither a long enough password nor a passkey is refused without spending the link. An admin's everyday account (`web/pages/everyday.ts`) still makes its account with a passkey only (`web/passkey-ceremony.ts`, challenges from `POST /settings/everyday/options`): it reads no password, and without JavaScript the page says a passkey needs it. |
| 365 | 365 | |
| 366 | Password checks are bounded (`src/core/hash-gate.ts`): at most 4 argon2 verifications run at once, and a password over 1024 bytes is never hashed and cannot be set (sign-up, claim and change-password refuse it with "Use 1024 bytes or fewer."). A check over the cap is refused at once, not queued: the caller sees the same `throttled` answer as a full sign-in window (429 with `Retry-After: 1` on HTTP Basic, the API and the sign-in page, "try again" in the terminal; an over-long Basic password is a plain 401), and the attempt is given back to the sign-in windows. The 4-hash cap is a deliberate trade-off: an attacker who keeps 4 hashes busy can make real sign-ins get "try again in 1s"; the per-source sign-in window is what limits how long they can. | |
| 367 | ||
| 366 | 368 | A forge with a parent (`FORGE_PARENT_URL`, or the `upstream` in a spawned |
| 367 | 369 | forge's `parent.json`) lists the parent under "Link an account you have" on `/signin`. The forge |
| 368 | 370 | registers itself once at the parent's `/oauth/register` (the client id is kept |
src/actions/users.ts +10 −2
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | * an account authenticates with over the SSH listener. |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | import { withinPasswordCap } from "../core/hash-gate.ts"; | |
| 6 | 7 | import { emailChanged, welcome } from "./verify.ts"; |
| 7 | 8 | import type { App } from "../app.ts"; |
| 8 | 9 | import type { Credential } from "../auth/webauthn.ts"; |
| @@ -126,6 +127,7 @@ export async function signUp( | ||
| 126 | 127 | const email = (input.email ?? "").trim(); |
| 127 | 128 | if (email !== "" && !givenEmail(email)) return err("invalid_email"); |
| 128 | 129 | if (input.password.length < MIN_SIGNUP_PASSWORD) return err("weak_password"); |
| 130 | if (!withinPasswordCap(input.password)) return err("password_too_long"); | |
| 129 | 131 | const secret = app.config.turnstileSecret; |
| 130 | 132 | // The terminal cannot render a widget, so with a secret set it brings no |
| 131 | 133 | // token and is refused, the forge's first account too: sign up on the web page. |
| @@ -316,7 +318,8 @@ export type ChangePasswordError = | ||
| 316 | 318 | | "unauthorized" |
| 317 | 319 | | "bad_credentials" |
| 318 | 320 | | "throttled" |
| 319 | | "weak_password"; | |
| 321 | | "weak_password" | |
| 322 | | "password_too_long"; | |
| 320 | 323 | |
| 321 | 324 | /** Replace the password; the current one must be presented and verified. */ |
| 322 | 325 | export async function changePassword( |
| @@ -333,7 +336,12 @@ export async function changePassword( | ||
| 333 | 336 | }); |
| 334 | 337 | if (!verified.ok) return err(verified.error.reason); |
| 335 | 338 | const changed = await rehash(app.db, user.value.id, input.next); |
| 336 | if (!changed.ok) return err(changed.error === "weak_password" ? "weak_password" : "unauthorized"); | |
| 339 | if (!changed.ok) | |
| 340 | return err( | |
| 341 | changed.error === "weak_password" || changed.error === "password_too_long" | |
| 342 | ? changed.error | |
| 343 | : "unauthorized", | |
| 344 | ); | |
| 337 | 345 | app.bus.append({ |
| 338 | 346 | kind: "user.updated", |
| 339 | 347 | actorId: user.value.id, |
src/core/hash-gate.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | /** | |
| 2 | * How many argon2 checks may run at once. Each one holds 64 MiB and a core, | |
| 3 | * and the sign-in windows count attempts per minute, not attempts in flight: | |
| 4 | * a burst of distinct accounts from many sources would still start them all | |
| 5 | * together. Past the cap an attempt is refused at once, not queued, so a | |
| 6 | * flood cannot pile up memory behind the hash. Passwords past `MAX_PASSWORD` | |
| 7 | * bytes are never hashed: no person types one, and the cost grows with length. | |
| 8 | */ | |
| 9 | ||
| 10 | export const MAX_CONCURRENT_HASHES = 4; | |
| 11 | export const MAX_PASSWORD = 1024; | |
| 12 | /** Seconds a refused caller is told to wait. */ | |
| 13 | export const GATE_RETRY_AFTER = 1; | |
| 14 | /** What a person is told when a password they set is past the cap. */ | |
| 15 | export const PASSWORD_TOO_LONG = `Use ${MAX_PASSWORD} bytes or fewer.`; | |
| 16 | ||
| 17 | /** Whether a password is within the cap, counted in bytes as it is hashed. */ | |
| 18 | export const withinPasswordCap = (password: string): boolean => | |
| 19 | Buffer.byteLength(password) <= MAX_PASSWORD; | |
| 20 | ||
| 21 | let running = 0; | |
| 22 | ||
| 23 | /** Run `work` in a free slot, or return undefined when every slot is taken. */ | |
| 24 | export async function withHashSlot<T>( | |
| 25 | work: () => Promise<T>, | |
| 26 | cap: number = MAX_CONCURRENT_HASHES, | |
| 27 | ): Promise<T | undefined> { | |
| 28 | if (running >= cap) return undefined; | |
| 29 | running += 1; | |
| 30 | try { | |
| 31 | return await work(); | |
| 32 | } finally { | |
| 33 | running -= 1; | |
| 34 | } | |
| 35 | } |
src/core/users.ts +18 −2
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | * narrower; there is no second identity system. |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | import { GATE_RETRY_AFTER, withinPasswordCap, withHashSlot } from "./hash-gate.ts"; | |
| 6 | 7 | import { markUnproven, markVerified } from "./verify.ts"; |
| 7 | 8 | import type { Database } from "bun:sqlite"; |
| 8 | 9 | import { isSafeName } from "../git/guard.ts"; |
| @@ -88,6 +89,8 @@ export const isActive = (user: User): boolean => | ||
| 88 | 89 | export const hiddenFrom = (user: User, viewer: User | undefined): boolean => |
| 89 | 90 | user.kind === "user" && !isActive(user) && viewer?.isAdmin !== true; |
| 90 | 91 | |
| 92 | // The minimum counts characters, the cap (`hash-gate.ts`) counts bytes: the | |
| 93 | // hash reads bytes, and a person never meets the difference at these sizes. | |
| 91 | 94 | const MIN_PASSWORD = 10; |
| 92 | 95 | const MAX_EMAIL = 254; |
| 93 | 96 | const MAX_DISPLAY_NAME = 80; |
| @@ -98,6 +101,7 @@ export type CreateUserError = | ||
| 98 | 101 | | "username_reserved" |
| 99 | 102 | | "email_taken" |
| 100 | 103 | | "weak_password" |
| 104 | | "password_too_long" | |
| 101 | 105 | | "signup_closed"; |
| 102 | 106 | |
| 103 | 107 | /** |
| @@ -280,6 +284,8 @@ export async function createUser( | ||
| 280 | 284 | const password = agent ? undefined : input.password; |
| 281 | 285 | if (password !== undefined && password.length < MIN_PASSWORD) |
| 282 | 286 | return err("weak_password"); |
| 287 | if (password !== undefined && !withinPasswordCap(password)) | |
| 288 | return err("password_too_long"); | |
| 283 | 289 | |
| 284 | 290 | const existing = db |
| 285 | 291 | .query< |
| @@ -423,8 +429,9 @@ export async function changePassword( | ||
| 423 | 429 | db: Database, |
| 424 | 430 | userId: string, |
| 425 | 431 | next: string, |
| 426 | ): Promise<Result<void, "weak_password" | "no_such_user">> { | |
| 432 | ): Promise<Result<void, "weak_password" | "password_too_long" | "no_such_user">> { | |
| 427 | 433 | if (next.length < MIN_PASSWORD) return err("weak_password"); |
| 434 | if (!withinPasswordCap(next)) return err("password_too_long"); | |
| 428 | 435 | const hash = await Bun.password.hash(next, "argon2id"); |
| 429 | 436 | const changed = db |
| 430 | 437 | .query("UPDATE user SET password_hash = ?1 WHERE id = ?2 AND kind = 'user'") |
| @@ -494,10 +501,19 @@ export async function verifyPasswordThrottled( | ||
| 494 | 501 | db: Database, |
| 495 | 502 | attempt: PasswordAttempt, |
| 496 | 503 | ): Promise<Result<User, PasswordRefusal>> { |
| 504 | if (!withinPasswordCap(attempt.password)) | |
| 505 | return err({ reason: "bad_credentials" }); | |
| 497 | 506 | const admission = admitSignin(db, attempt.username, attempt.source); |
outdated
The give-back and the 1s retryAfter on a full gate are untested. Add a test through |
||
| 498 | 507 | if (!admission.admitted) |
| 499 | 508 | return err({ reason: "throttled", retryAfter: admission.retryAfter }); |
| 500 | const checked = await checkPassword(db, attempt.username, attempt.password); | |
| 509 | const checked = await withHashSlot(() => | |
| 510 | checkPassword(db, attempt.username, attempt.password), | |
| 511 | ); | |
| 512 | if (checked === undefined) { | |
outdated
This 429 only reaches the sign-in page and the API. HTTP Basic in src/http/server.ts drops it to a 401, so the bound is invisible there. Carry the retryAfter through, and test it. |
||
| 513 | // Never hashed, so it is not a guess: give it back and ask for a moment. | |
| 514 | admission.succeeded(); | |
| 515 | return err({ reason: "throttled", retryAfter: GATE_RETRY_AFTER }); | |
| 516 | } | |
| 501 | 517 | if (!checked.ok) return err({ reason: "bad_credentials" }); |
| 502 | 518 | admission.succeeded(); |
| 503 | 519 | return checked; |
src/http/context.ts +3
| @@ -21,6 +21,9 @@ export interface Auth { | ||
| 21 | 21 | readonly sessionId?: string; |
| 22 | 22 | /** Set for a proposal token: all it may do (`http/proposal_gate.ts`). */ |
| 23 | 23 | readonly proposal?: Proposal; |
| 24 | /** Set when a Basic password was turned away by the sign-in windows or the | |
| 25 | * hash gate, not found wrong: the request answers 429, not 401. */ | |
| 26 | readonly retryAfter?: number; | |
| 24 | 27 | } |
| 25 | 28 | |
| 26 | 29 | export interface Ctx { |
src/http/server.ts +7 −1
| @@ -494,6 +494,7 @@ import { | ||
| 494 | 494 | notFound, |
| 495 | 495 | prefersHtml, |
| 496 | 496 | securityFloor, |
| 497 | tooManyAttempts, | |
| 497 | 498 | } from "./respond.ts"; |
| 498 | 499 | import { compress } from "./compress.ts"; |
| 499 | 500 | import { RateLimiter, type Verdict } from "../core/ratelimit.ts"; |
| @@ -590,7 +591,7 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> { | ||
| 590 | 591 | } |
| 591 | 592 | if (username !== "" && secret !== "") { |
| 592 | 593 | // Counted in the sign-in windows like every password door; past them |
| 593 | // the right password is refused too, so a guesser learns nothing. | |
| 594 | // the right password is refused too, with a 429 and Retry-After. | |
| 594 | 595 | const verified = await verifyPasswordThrottled(app.db, { |
| 595 | 596 | username, |
| 596 | 597 | password: secret, |
| @@ -598,6 +599,10 @@ async function resolveAuth(app: App, request: Request): Promise<Auth> { | ||
| 598 | 599 | }); |
| 599 | 600 | if (verified.ok) |
| 600 | 601 | return { user: verified.value, scope: "admin", via: "password" }; |
| 602 | // Not a wrong password: a git client or script must back off, not | |
| 603 | // read this as bad credentials. | |
| 604 | if (verified.error.reason === "throttled") | |
| 605 | return { ...NO_AUTH, retryAfter: verified.error.retryAfter }; | |
| 601 | 606 | } |
| 602 | 607 | } |
| 603 | 608 | return NO_AUTH; |
| @@ -2422,6 +2427,7 @@ async function answerGet( | ||
| 2422 | 2427 | return noRoute(request.method, url.pathname); |
| 2423 | 2428 | } |
| 2424 | 2429 | const auth = await resolveAuth(app, request); |
| 2430 | if (auth.retryAfter !== undefined) return tooManyAttempts(auth.retryAfter); | |
| 2425 | 2431 | noteUsage(request, { pattern: matched.pattern, role: roleOf(auth) }); |
| 2426 | 2432 | // From here a body's cap depends on who is asking (`http/limits.ts`), and |
| 2427 | 2433 | // a stream this request opens is the account's, to end with it. |
src/tui/screens/common.ts +2
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * of them. Nothing here knows about repos or issues — that is the point. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { PASSWORD_TOO_LONG } from "../../core/hash-gate.ts"; | |
| 10 | 11 | import { refusalText } from "../../http/refusal.ts"; |
| 11 | 12 | import { NOT_A_MEMBER } from "../../core/roles.ts"; |
| 12 | 13 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -841,6 +842,7 @@ const MESSAGES: Record<string, string> = { | ||
| 841 | 842 | key_exists: "that key is already registered", |
| 842 | 843 | taken: "that name is taken", |
| 843 | 844 | weak_password: "the password is too short (10 characters at least)", |
| 845 | password_too_long: PASSWORD_TOO_LONG.toLowerCase().replace(/\.$/, ""), | |
| 844 | 846 | bad_credentials: "wrong username or password", |
| 845 | 847 | throttled: "too many attempts: try again in a minute", |
Low: the gate refuses with Retry-After 1s, but the terminal still says "try again in a minute" for every |
||
| 846 | 848 | already_forked: "you already have a fork of this repo", |
src/web/pages/account.ts +3
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | * has been read once. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { PASSWORD_TOO_LONG } from "../../core/hash-gate.ts"; | |
| 12 | 13 | import { resendVerification } from "../../actions/verify.ts"; |
| 13 | 14 | import { clientAddress } from "../../http/peer.ts"; |
| 14 | 15 | import { emailVerified } from "../../core/verify.ts"; |
| @@ -506,6 +507,8 @@ export async function passwordUpdate(ctx: Ctx): Promise<Response> { | ||
| 506 | 507 | field: "current" as const, |
| 507 | 508 | text: "Too many attempts. Try again in a minute.", |
| 508 | 509 | } |
| 510 | : changed.error === "password_too_long" | |
| 511 | ? { field: "next" as const, text: PASSWORD_TOO_LONG } | |
| 509 | 512 | : changed.error === "weak_password" |
| 510 | 513 | ? { |
| 511 | 514 | field: "next" as const, |
src/web/pages/claim.ts +2
| @@ -18,6 +18,7 @@ | ||
| 18 | 18 | * so before anyone submits, and where to remove it. |
| 19 | 19 | */ |
| 20 | 20 | |
| 21 | import { PASSWORD_TOO_LONG, withinPasswordCap } from "../../core/hash-gate.ts"; | |
| 21 | 22 | import { givenEmail, MIN_SIGNUP_PASSWORD, noEmail } from "../../actions/users.ts"; |
| 22 | 23 | import type { Credential } from "../../auth/webauthn.ts"; |
| 23 | 24 | import { crossOrigin, type Ctx } from "../../http/context.ts"; |
| @@ -303,6 +304,7 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> { | ||
| 303 | 304 | const errors = problems(ctx, values.username, values.email.trim(), withKey); |
| 304 | 305 | if (!withKey && password.length < MIN_SIGNUP_PASSWORD) |
| 305 | 306 | errors.password = `Passwords need at least ${MIN_SIGNUP_PASSWORD} characters.`; |
| 307 | else if (!withinPasswordCap(password)) errors.password = PASSWORD_TOO_LONG; | |
| 306 | 308 | if (Object.keys(errors).length > 0) return again(errors); |
| 307 | 309 | // A key in the form is the passkey door; none is the password door. |
| 308 | 310 | let credential: Credential | undefined; |
src/web/pages/site.ts +2
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * typed still in it. A dead end that loses input is a bug, not a message. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { PASSWORD_TOO_LONG } from "../../core/hash-gate.ts"; | |
| 11 | 12 | import { NOT_A_MEMBER } from "../../core/roles.ts"; |
| 12 | 13 | import { storageLimitMessage } from "../../core/storage.ts"; |
| 13 | 14 | import { signOutHere } from "../../actions/signins.ts"; |
| @@ -661,6 +662,7 @@ export async function signupSubmit(ctx: Ctx): Promise<Response> { | ||
| 661 | 662 | username_taken: { username: "That username is taken." }, |
| 662 | 663 | username_reserved: { username: "That name is reserved." }, |
| 663 | 664 | email_taken: { email: "That email already has an account." }, |
| 665 | password_too_long: { password: PASSWORD_TOO_LONG }, | |
| 664 | 666 | weak_password: { password: "Passwords need at least 12 characters." }, |
| 665 | 667 | invalid_email: { email: "That does not look like an email address." }, |
| 666 | 668 | human_check: { human: HUMAN_CHECK_MESSAGE }, |
test/claim-link.test.ts +5
| @@ -229,6 +229,11 @@ describe("the claim link in the fragment, and its re-issue", () => { | ||
| 229 | 229 | expect(weak.status).toBe(422); |
| 230 | 230 | expect(await weak.text()).toContain("Passwords need at least 12 characters."); |
| 231 | 231 | expect(getUserByName(child.db, "maria-owner")).toBeUndefined(); |
| 232 | // So is one past the hash cap, in words, also without spending the link. | |
| 233 | const long = await claim({ token: tokenOf(second), username: "maria-owner", password: "x".repeat(1025) }); | |
| 234 | expect(long.status).toBe(422); | |
| 235 | expect(await long.text()).toContain("Use 1024 bytes or fewer."); | |
| 236 | expect(getUserByName(child.db, "maria-owner")).toBeUndefined(); | |
| 232 | 237 | // A malformed address is refused on the password door, and so is a stand-in typed by hand. |
| 233 | 238 | for (const email of ["not-an-address", "[email protected]"]) { |
| 234 | 239 | const bad = await claim({ token: tokenOf(second), username: "maria-owner", email, password: PASSWORD }); |
test/hash-gate.test.ts added +135
| @@ -0,0 +1,135 @@ | ||
| 1 | /** | |
| 2 | * Password hashing is bounded (#3015): four argon2 checks at once, and no | |
| 3 | * password past 1024 bytes hashed or set. A full gate answers `throttled` | |
| 4 | * (429 with Retry-After on Basic, the API and the sign-in page) and gives the | |
| 5 | * attempt back to the sign-in windows; an over-long password is refused where | |
| 6 | * it is set, in words, on every surface that sets one. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { signUp, changePassword as changeOwnPassword } from "../src/actions/users.ts"; | |
| 14 | import { MAX_CONCURRENT_HASHES, MAX_PASSWORD, withHashSlot } from "../src/core/hash-gate.ts"; | |
| 15 | import { admitSignin } from "../src/core/signin-window.ts"; | |
| 16 | import { changePassword, createUser, getUserById, verifyPasswordThrottled } from "../src/core/users.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | ||
| 19 | const LONG = "x".repeat(MAX_PASSWORD + 1); | |
| 20 | const GOOD = "a-good-passphrase"; | |
| 21 | ||
| 22 | /** Hold every hash slot until the returned function is called. */ | |
| 23 | function fillGate(): () => Promise<void> { | |
| 24 | let release!: () => void; | |
| 25 | const held = new Promise<void>((r) => (release = r)); | |
| 26 | const holders = Array.from({ length: MAX_CONCURRENT_HASHES }, () => withHashSlot(() => held)); | |
| 27 | return async () => { | |
| 28 | release(); | |
outdated
Imports belong at the top of the file. Move these up and fold the two describe blocks together. |
||
| 29 | await Promise.all(holders); | |
| 30 | }; | |
| 31 | } | |
| 32 | ||
| 33 | test("slots beyond the cap are refused at once, and freed after", async () => { | |
| 34 | let release!: () => void; | |
| 35 | const held = new Promise<void>((r) => (release = r)); | |
| 36 | const first = withHashSlot(() => held.then(() => 1), 1); | |
| 37 | expect(await withHashSlot(async () => 2, 1)).toBeUndefined(); | |
| 38 | release(); | |
| 39 | expect(await first).toBe(1); | |
| 40 | expect(await withHashSlot(async () => 3, 1)).toBe(3); | |
| 41 | }); | |
| 42 | ||
| 43 | test("a slot is freed when the work throws", async () => { | |
| 44 | await withHashSlot(async () => { | |
| 45 | throw new Error("x"); | |
| 46 | }, 1).catch(() => {}); | |
| 47 | expect(await withHashSlot(async () => 4, 1)).toBe(4); | |
| 48 | }); | |
| 49 | ||
| 50 | describe("the gate and the cap on a forge", () => { | |
| 51 | let dir: string; | |
| 52 | let app: App; | |
| 53 | let servers: Servers; | |
| 54 | let ginaId: string; | |
| 55 | const basic = (user: string, password: string) => | |
| 56 | fetch(`${servers.url}/api/v1/user`, { | |
| 57 | headers: { authorization: `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}` }, | |
| 58 | }); | |
| 59 | const form = (path: string, fields: Record<string, string>) => | |
| 60 | fetch(`${servers.url}${path}`, { | |
| 61 | method: "POST", | |
| 62 | headers: { origin: servers.url }, | |
| 63 | body: new URLSearchParams(fields), | |
| 64 | redirect: "manual", | |
| 65 | }); | |
| 66 | ||
| 67 | beforeAll(async () => { | |
| 68 | dir = await mkdtemp(join(tmpdir(), "forge-gate-")); | |
| 69 | app = createApp({ dataDir: dir, port: 0 }); | |
| 70 | servers = await serve(app); | |
| 71 | const made = await createUser(app.db, { origin: "seed", trusted: true, | |
| 72 | username: "gina", email: "[email protected]", password: GOOD, adminIfFirst: true }); | |
| 73 | if (!made.ok) throw new Error(made.error); | |
| 74 | ginaId = made.value.id; | |
| 75 | }); | |
| 76 | afterAll(async () => { | |
| 77 | await servers.stop?.(); | |
| 78 | await rm(dir, { recursive: true, force: true }); | |
| 79 | }); | |
| 80 | ||
| 81 | test("a full gate answers throttled with 1s and gives the window attempt back", async () => { | |
| 82 | const release = fillGate(); | |
| 83 | const attempt = { username: "gina", password: GOOD, source: "10.0.0.9" }; | |
| 84 | // Far more tries than the per-account window (20/min) allows: each is | |
| 85 | // refused by the gate, so none may be counted against it. | |
| 86 | for (let i = 0; i < 40; i++) { | |
| 87 | const out = await verifyPasswordThrottled(app.db, attempt); | |
| 88 | expect(out.ok).toBe(false); | |
| 89 | if (!out.ok) expect(out.error).toEqual({ reason: "throttled", retryAfter: 1 }); | |
| 90 | } | |
| 91 | await release(); | |
| 92 | expect(admitSignin(app.db, "gina", "10.0.0.9").admitted).toBe(true); | |
| 93 | expect((await verifyPasswordThrottled(app.db, attempt)).ok).toBe(true); | |
| 94 | }); | |
| 95 | ||
| 96 | test("Basic with the gate full is 429 with Retry-After, not a 401", async () => { | |
| 97 | const release = fillGate(); | |
| 98 | const res = await basic("gina", GOOD); | |
| 99 | await release(); | |
| 100 | expect(res.status).toBe(429); | |
| 101 | expect(res.headers.get("retry-after")).toBe("1"); | |
| 102 | expect((await basic("gina", GOOD)).status).toBe(200); | |
| 103 | }); | |
| 104 | ||
| 105 | test("Basic with a password past the cap is a plain 401", async () => { | |
| 106 | const res = await basic("gina", LONG); | |
| 107 | expect(res.status).toBe(401); | |
| 108 | expect(res.headers.get("retry-after")).toBeNull(); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a password past the cap is refused where it is set", async () => { | |
| 112 | const made = await createUser(app.db, { origin: "seed", trusted: true, | |
| 113 | username: "long", email: "[email protected]", password: LONG }); | |
| 114 | expect(made.ok ? "ok" : made.error).toBe("password_too_long"); | |
| 115 | const changed = await changePassword(app.db, ginaId, LONG); | |
| 116 | expect(changed.ok ? "ok" : changed.error).toBe("password_too_long"); | |
| 117 | }); | |
| 118 | ||
| 119 | test("sign-up and the own-password change refuse it too", async () => { | |
| 120 | const signed = await signUp(app, { username: "lena", password: LONG }, { slot: { admitted: true } as never }); | |
| 121 | expect(signed.ok ? "ok" : signed.error).toBe("password_too_long"); | |
| 122 | const changed = await changeOwnPassword( | |
| 123 | app, | |
| 124 | getUserById(app.db, ginaId), | |
| 125 | { current: GOOD, next: LONG, source: "10.0.0.8" }, | |
| 126 | ); | |
| 127 | expect(changed.ok ? "ok" : changed.error).toBe("password_too_long"); | |
| 128 | }); | |
| 129 | ||
| 130 | test("the sign-up page says so in words", async () => { | |
| 131 | const res = await form("/signup", { username: "mila", password: LONG }); | |
| 132 | expect(res.status).toBe(422); | |
| 133 | expect(await res.text()).toContain("Use 1024 bytes or fewer."); | |
| 134 | }); | |
| 135 | }); |
test/signin-password-doors.test.ts +5 −1
| @@ -67,7 +67,11 @@ describe("password doors share the sign-in windows", () => { | ||
| 67 | 67 | test("HTTP Basic with a password refuses the right password after the window fills", async () => { |
| 68 | 68 | const { url } = await forge(); |
| 69 | 69 | for (let i = 0; i < TRIES; i++) expect((await basic(url, `wrong-${i}`)).status).toBe(401); |
| 70 | expect((await basic(url, PASSWORD)).status).toBe(401); | |
| 70 | // Past the window even the right password is turned away, as a 429 with | |
| 71 | // Retry-After, the answer every other password door gives. | |
| 72 | const refused = await basic(url, PASSWORD); | |
| 73 | expect(refused.status).toBe(429); | |
| 74 | expect(Number(refused.headers.get("retry-after"))).toBeGreaterThan(0); | |
| 71 | 75 | }); |
| 72 | 76 | |
| 73 | 77 | test("a right password is given back: git sending it on every request is not throttled", async () => { |
"the same throttled answer ... 429 with Retry-After on the API" is not true for Basic auth. Make it true or say Basic answers 401.