Skip to content

fix(auth): harden basic-auth realm handling and credential timing#1468

Merged
pi0 merged 4 commits into
mainfrom
fix/basic-auth-hardening
Jul 15, 2026
Merged

fix(auth): harden basic-auth realm handling and credential timing#1468
pi0 merged 4 commits into
mainfrom
fix/basic-auth-hardening

Conversation

@pi0x

@pi0x pi0x commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Hardens requireBasicAuth/basicAuth for realm consistency and timing defense-in-depth. The first challenge (missing Authorization header) now carries the configured realm, and the documented "auth" default is applied consistently to both the challenge and event.context.basicAuth.realm. Username and password comparisons are now evaluated unconditionally so failure timing no longer distinguishes an unknown user from a wrong password, and empty user-id/password are permitted when a custom validate function is supplied (RFC 7617).

Parsing nits: accept 1*SP between the Basic scheme and token (RFC 9110), and sanitize the realm to the RFC 9110 §5.6.4 quoted-string charset instead of JSON.stringify, which previously threw on non-Latin-1 realms. Added regression tests for all three observable behaviors; full suite and lint pass.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved Basic Authentication challenges by always using the configured realm, safely escaping it for the WWW-Authenticate header.
    • Refined custom credential validation so empty usernames/passwords are handled correctly when a validator is provided.
    • Enhanced authentication failure handling and robustness, including support for non-Latin realm values.
    • Accepted Authorization headers that include extra spaces between the scheme and credentials.
  • Tests
    • Added authentication coverage for realm in challenges, spacing variations, and non-Latin realm values.

- Carry the configured realm on the missing-header challenge and apply
  the documented "auth" default consistently (challenge + context).
- Skip the empty user-id/password rejection when a custom validate
  function is provided (RFC 7617 permits empty credentials).
- Evaluate username and password comparisons unconditionally so failure
  timing does not distinguish an unknown user from a wrong password.
- Accept 1*SP between the "Basic" scheme and token (RFC 9110).
- Sanitize the realm to the RFC 9110 §5.6.4 quoted-string charset instead
  of JSON.stringify, which threw on non-Latin-1 realms.

Co-Authored-By: Claude Fable 5 <[email protected]>
@pi0x
pi0x requested a review from pi0 as a code owner July 14, 2026 12:37
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 83beb115-9fb2-449a-b5f6-67a8aa36bdef

📥 Commits

Reviewing files that changed from the base of the PR and between 89973f9 and 2ad947a.

📒 Files selected for processing (2)
  • src/utils/auth.ts
  • test/auth.test.ts

📝 Walkthrough

Walkthrough

Basic authentication now normalizes realms, changes custom-validator handling of empty credentials, applies jitter on failures, and safely formats authentication challenges. Tests cover configured realms, flexible credential spacing, and non-Latin-1 realm values.

Changes

Basic authentication behavior

Layer / File(s) Summary
Authentication validation and challenge handling
src/utils/auth.ts
requireBasicAuth normalizes realms, updates custom-validator behavior, applies failure jitter, and stores the normalized realm. authFailed sanitizes quoted realms in authentication challenges.
Authentication behavior coverage
test/auth.test.ts
Tests cover configured realms, multiple spaces after Basic, and non-Latin-1 realm values.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • h3js/h3#1283: Overlaps in requireBasicAuth failure timing, realm handling, and validation paths.
  • h3js/h3#1393: Covers related Basic-auth parsing and 401 failure handling changes.

Suggested reviewers: pi0

Poem

A rabbit checks the realm at night,
Escapes each quote and makes it right.
Basic credentials hop through space,
While jitter masks the failure pace.
A tidy challenge greets the ear—
“401, but safely rendered here!”

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main auth hardening changes, including realm handling and credential timing fixes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/basic-auth-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pi0x pi0x left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Verdict: APPROVE (independent review — I did not author this change).

Reviewed the diff, checked out the branch, and ran the suite: pnpm vitest run test/auth.test.ts → 42 passed; pnpm lint (oxlint + oxfmt) clean. I also reverted only the source (keeping the new tests) and confirmed all three regression tests fail against main, so they are genuine regressions, not tautologies.

What I verified:

  1. Constant-shape timing. usernameOk/passwordOk/validateOk are each computed on their own line before the if, so both timingSafeEqual calls always execute — the only short-circuit (!opts.username || …) depends on static config, not request data, so the timing shape is invariant across requests for a given config. The final if (!usernameOk || !passwordOk || !validateOk) short-circuits only over already-computed booleans. This correctly removes the old ||-chain side-channel (unknown-user skipping the password compare). randomJitter() still fires on failure. Correct.

  2. Realm sanitizer. quoteRealm matches RFC 9110 §5.6.4: strips everything outside HTAB / SP / %x21-7E / obs-text(%x80-FF) (so control chars, DEL 0x7F, and >0xFF code points go), then escapes DQUOTE and backslash as quoted-pairs. This is the correct qdtext + quoted-pair charset and avoids the JSON.stringify non-Latin-1 header throw. Correct.

  3. Empty-credential change does not weaken the default path. if (!opts.validate && (!username || !password)) still rejects empty user-id/password whenever no custom validate is supplied (including the common basicAuth({ password }) case). Empty creds only reach a user-provided validate, per RFC 7617. Correct.

  4. Default realm "auth" is now consistent across the missing-header challenge, all other challenges, context.basicAuth.realm, and the JSDoc (Defaults to "auth"). No existing test regressed.

Minor (non-blocking) notes:

  • The scheme regex /^basic\s+(.+)$/i is commented as "one or more spaces" but \s also matches TAB/CR/LF/FF/VT — broader than RFC 9110 1*SP. Harmless in practice (the header is already line-parsed by the HTTP layer and token68 is base64, so atob rejects anything odd), but the comment slightly overstates strictness. Consider [ ]+ or [\t ]+ if you want to match the comment, or reword the comment.

  • Expected semantic overlap with #1463 (UTF-8 decode): both edit the authFailed www-authenticate line and the decode block. A merge conflict is expected there. Whichever lands second should reconcile so the final code keeps all three: the quoteRealm sanitizer, the TextDecoder("utf-8") decode, and charset="UTF-8" in the challenge. Flagging only — not counted against this PR.

Solid, well-tested hardening. Approving.

🤖 Generated with Claude Code

RFC 9110 defines the separator as 1*SP; \s also matched TAB/CR/LF/FF/VT.

Co-Authored-By: Claude Fable 5 <[email protected]>
@pi0x

pi0x commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the review nit in 6d3be92: tightened the scheme separator regex to /^basic +(.+)$/i (literal spaces only, matching RFC 9110's 1*SP) instead of \s+. Auth tests and lint pass.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/utils/auth.ts`:
- Around line 81-89: Update the usernameOk and passwordOk checks in the
authentication validation flow to skip timingSafeEqual only when opts.username
or opts.password is undefined, not when they are empty strings. Preserve the
existing unconditional comparison behavior and failure handling so explicitly
configured empty strings require an exact match.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f6daebc5-d2de-43f4-9585-389084b1cdb2

📥 Commits

Reviewing files that changed from the base of the PR and between 2264316 and 670f256.

📒 Files selected for processing (2)
  • src/utils/auth.ts
  • test/auth.test.ts

Comment thread src/utils/auth.ts
Comment on lines +81 to 89
// Evaluate all comparisons unconditionally so failure timing does not
// distinguish an unknown user from a wrong password.
const usernameOk = !opts.username || timingSafeEqual(username, opts.username);
const passwordOk = !opts.password || timingSafeEqual(password, opts.password);
const validateOk = !opts.validate || (await opts.validate(username, password));
if (!usernameOk || !passwordOk || !validateOk) {
await randomJitter();
throw authFailed(event, opts?.realm);
throw authFailed(event, realm);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Prevent authentication bypass from empty string configurations.

The truthiness checks (!opts.username, !opts.password) will evaluate to true if a developer explicitly configures them as empty strings (""). This causes the validation to silently skip the timingSafeEqual check, effectively acting as a wildcard that accepts any username or password from the client.

Use explicit undefined checks to ensure empty string configurations securely enforce an exact match.

🔒️ Proposed fix to securely handle empty string options
-  const usernameOk = !opts.username || timingSafeEqual(username, opts.username);
-  const passwordOk = !opts.password || timingSafeEqual(password, opts.password);
-  const validateOk = !opts.validate || (await opts.validate(username, password));
+  const usernameOk = opts.username === undefined || timingSafeEqual(username, opts.username);
+  const passwordOk = opts.password === undefined || timingSafeEqual(password, opts.password);
+  const validateOk = opts.validate === undefined || (await opts.validate(username, password));
   if (!usernameOk || !passwordOk || !validateOk) {
     await randomJitter();
     throw authFailed(event, realm);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Evaluate all comparisons unconditionally so failure timing does not
// distinguish an unknown user from a wrong password.
const usernameOk = !opts.username || timingSafeEqual(username, opts.username);
const passwordOk = !opts.password || timingSafeEqual(password, opts.password);
const validateOk = !opts.validate || (await opts.validate(username, password));
if (!usernameOk || !passwordOk || !validateOk) {
await randomJitter();
throw authFailed(event, opts?.realm);
throw authFailed(event, realm);
}
// Evaluate all comparisons unconditionally so failure timing does not
// distinguish an unknown user from a wrong password.
const usernameOk = opts.username === undefined || timingSafeEqual(username, opts.username);
const passwordOk = opts.password === undefined || timingSafeEqual(password, opts.password);
const validateOk = opts.validate === undefined || (await opts.validate(username, password));
if (!usernameOk || !passwordOk || !validateOk) {
await randomJitter();
throw authFailed(event, realm);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/auth.ts` around lines 81 - 89, Update the usernameOk and passwordOk
checks in the authentication validation flow to skip timingSafeEqual only when
opts.username or opts.password is undefined, not when they are empty strings.
Preserve the existing unconditional comparison behavior and failure handling so
explicitly configured empty strings require an exact match.

…e contract

- Apply randomJitter() inside authFailed so every 401 path (missing header,
  bad scheme, decode failure, missing colon, empty creds, mismatch) is
  delayed uniformly, closing the parse-vs-mismatch timing asymmetry
- Document that a custom `validate` receives decoded credentials as-is,
  including empty strings (RFC 7617), and must reject them explicitly
- Drop the unreachable `= "auth"` default on authFailed's realm param;
  the realm is computed once at the call site

Co-Authored-By: Claude Opus 4.8 <[email protected]>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
src/utils/auth.ts (1)

88-91: 🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Prevent authentication bypass from empty string configurations.

The truthiness checks (!opts.username, !opts.password) will evaluate to true if a developer explicitly configures them as empty strings (""). This causes the validation to silently skip the timingSafeEqual check, effectively acting as a wildcard that accepts any username or password from the client.

Use explicit undefined checks to ensure empty string configurations securely enforce an exact match.

🔒️ Proposed fix to securely handle empty string options
-  const usernameOk = !opts.username || timingSafeEqual(username, opts.username);
-  const passwordOk = !opts.password || timingSafeEqual(password, opts.password);
-  const validateOk = !opts.validate || (await opts.validate(username, password));
+  const usernameOk = opts.username === undefined || timingSafeEqual(username, opts.username);
+  const passwordOk = opts.password === undefined || timingSafeEqual(password, opts.password);
+  const validateOk = opts.validate === undefined || (await opts.validate(username, password));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/auth.ts` around lines 88 - 91, Update the usernameOk and passwordOk
checks in the authentication validation flow to bypass timingSafeEqual only when
opts.username or opts.password is undefined, not when configured as an empty
string. Preserve exact matching for all defined values, including empty strings,
while leaving validateOk unchanged.
🧹 Nitpick comments (1)
src/utils/auth.ts (1)

56-56: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove redundant optional chaining.

Since opts is unconditionally accessed earlier on line 49 (opts.validate), it is guaranteed to be defined here. The optional chaining operator ?. is redundant and can be safely removed.

♻️ Proposed refactor
-  const realm = opts?.realm ?? "auth";
+  const realm = opts.realm ?? "auth";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/auth.ts` at line 56, Remove the redundant optional chaining from
the realm assignment in the auth logic, changing the access to use the
already-validated opts object directly. Preserve the existing "auth" fallback
via nullish coalescing.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/utils/auth.ts`:
- Around line 88-91: Update the usernameOk and passwordOk checks in the
authentication validation flow to bypass timingSafeEqual only when opts.username
or opts.password is undefined, not when configured as an empty string. Preserve
exact matching for all defined values, including empty strings, while leaving
validateOk unchanged.

---

Nitpick comments:
In `@src/utils/auth.ts`:
- Line 56: Remove the redundant optional chaining from the realm assignment in
the auth logic, changing the access to use the already-validated opts object
directly. Preserve the existing "auth" fallback via nullish coalescing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 70e61038-f136-477e-b138-6f17387666fa

📥 Commits

Reviewing files that changed from the base of the PR and between 670f256 and 89973f9.

📒 Files selected for processing (1)
  • src/utils/auth.ts

# Conflicts:
#	src/utils/auth.ts
#	test/auth.test.ts
@pi0
pi0 merged commit fc2d5b5 into main Jul 15, 2026
5 of 7 checks passed
@pi0
pi0 deleted the fix/basic-auth-hardening branch July 15, 2026 08:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants