Skip to content

Harden passkey/WebAuthn registration against malformed input - #3076

Open
jeremy wants to merge 2 commits into
mainfrom
security/passkey-webauthn-hardening
Open

Harden passkey/WebAuthn registration against malformed input#3076
jeremy wants to merge 2 commits into
mainfrom
security/passkey-webauthn-hardening

Conversation

@jeremy

@jeremy jeremy commented Aug 26, 2026

Copy link
Copy Markdown
Member

A HackerOne researcher fuzzed POST /:slug/my/passkeys (2026-08-25 21:13–21:36 UTC, ~9 events, one actor) and produced five distinct uncaught 500s in the new ActionPack::WebAuthn / ActionPack::Passkey registration stack. On-call card: On Call #10239040047.

Security read up front: the verification stack itself is sound — the signed challenge is verified, origin and RP-ID hash are compared with ActiveSupport::SecurityUtils.secure_compare, cross-origin and token-binding responses are rejected, and each COSE algorithm is strictly matched to its key type. Every one of the five legs fails closed. None is an auth bypass, replay, origin/RP-ID confusion, or credential-swap. The defects are (a) validation failures and malformed input surfacing as 500s instead of a clean rejection, and (b) two schema/serialization bugs that also break legitimate authenticators.

The five probed failures and their verdict

Sentry Trigger Security verdict Fix
FIZZY-VC attestation fmt: "packed" (no verifier registered) Benign. Unverifiable format is correctly rejected (fails closed); no packed statement is ever trusted. Only the disposition was wrong (500 not 422). common error superclass + controller rescue → redirect w/ alert (not 500)
FIZZY-VD Ed25519 credential registration Genuine functional defect, not a vuln. EdDSA keys decode to a generic OpenSSL::PKey::PKey with no #to_der, crashing serialization. Narrow (ES256 is offered first and negotiated by dual-alg authenticators). public_key.to_derpublic_to_der (round-trips EC/RSA/Ed25519)
FIZZY-VE sign_count = 4294967295 Security-adjacent but fails closed today. Sign count is the replay/clone signal; the column was signed INT4 (max 2147483647) while WebAuthn counts are uint32. A spec-legal counter raised RangeError rather than truncating — no bad write, but the credential was unusable. widen sign_count to bigint
FIZZY-VF non-empty attStmt under fmt: "none" Benign. The none verifier correctly rejects a non-empty statement (fails closed). Disposition only. superclass + rescue → redirect (not 500)
FIZZY-VG re-registering an existing credential_id Benign — the global unique index is a defense (prevents credential-ID takeover). Raw RecordNotUnique was just an unfriendly 500. rescue RecordNotUnique → "already registered"

What the probing revealed beyond the five (fixed here)

  • CBOR memory-exhaustion DoS. Array.new(read_argument) { decode } pre-sized the backing store to an attacker-declared count. A five-byte input (0x9a ff ff ff ff) named a ~4.3-billion-element array and drove a ~34 GB allocation (reproduced: hangs the process). Nested containers amplified further. Fixed by bounding a definite-length container's declared count by the remaining input and building arrays incrementally so a lying count can never pre-allocate.
  • Residual malformed-input 500s. The common superclass only catches errors the code explicitly raises; several fuzzing vectors still escaped as TypeError/NoMethodError. Closed at the decode boundary: client data must be a JSON object, the challenge must be a string, tokenBinding must be an object, the attestation object must be a CBOR map with binary authData, and COSE coordinates must be byte strings.
  • RSA key validation. The old check measured the encoded modulus byte length (a leading-zero pad defeats it) and accepted any exponent, including e = 1 (verifies a forged signature with no private key — confirmed locally). Now validates the actual modulus bit length and rejects degenerate exponents. Reachability is low (registration is authenticated; the actor would only weaken their own credential), but it is cheap correctness in the exact COSE surface under probe.

Fixes

  • lib/action_pack/web_authn.rb — common ActionPack::WebAuthn::Error superclass for the five error classes.
  • app/controllers/my/passkeys_controller.rbcreate rescues WebAuthn::Error (→ alert) and RecordNotUnique (→ "already registered").
  • lib/action_pack/passkey.rbauthenticate rescues the superclass on the assertion path.
  • lib/action_pack/web_authn/public_key_credential.rbpublic_to_der.
  • lib/action_pack/web_authn/authenticator/response.rb, attestation.rb, cose_key.rb — decode-boundary type guards.
  • lib/action_pack/web_authn/cbor_decoder.rb — declared-length bound + incremental array build.
  • db/migrate/20260825120000_widen_action_pack_passkeys_sign_count_to_bigint.rb + db/schema.rbsign_countbigint.

Tests

New/extended coverage for every leg: controller graceful-rejection + duplicate handling (test/controllers/my/passkeys_controller_test.rb), Ed25519/EC to_h round-trip (public_key_credential_test.rb), uint32-max sign-count persistence (passkey_test.rb), CBOR declared-length DoS bounds (cbor_decoder_test.rb), COSE type/RSA-exponent/bit-length guards (cose_key_test.rb), malformed client-data/challenge/attestation rejection (attestation_response_test.rb), and the error-hierarchy invariant (web_authn_test.rb). Full bin/ci green (rubocop, brakeman, all audits, OSS + system tests).

Flagged, not fixed here (design-level, tracked on the card)

Not silently patched — surfaced for follow-up: challenge tokens are authenticated + expiring but not single-use / not bound to the identity (replayable within the TTL); the sign-count check/update is an unlocked read-check-write (racy under concurrency); the credential_id unique index uses a case-insensitive collation (utf8mb4_0900_ai_ci) while base64url IDs are case-sensitive, and the string(255) column is narrower than spec-legal credential IDs; userVerification: required is not server-enforced (no live impact — Fizzy uses the default preferred). These are separate from the 500 cluster and warrant their own assessment.

Response contract

Registration failures redirect back with a flash alert (HTTP 302), consistent with this controller's update/destroy actions and the Turbo form flow — not a bare 422 body. The security property is that malformed input fails closed with a clean, non-500 response. (An earlier draft of this description said "422"; the redirect is the intended UX.)

A HackerOne researcher fuzzed POST /:slug/my/passkeys and produced five
distinct uncaught 500s in the new ActionPack::WebAuthn / ActionPack::Passkey
stack. Every leg fails closed (no auth bypass, replay, or origin/RP-ID
confusion), but validation failures and malformed input surfaced as 500s
instead of a clean rejection, and two legs are genuine defects that also
affect legitimate authenticators.

Root fixes:

- Give the five WebAuthn error classes a common ActionPack::WebAuthn::Error
  superclass so a single rescue turns any parse/verification failure into a
  4xx. My::PasskeysController#create now redirects with an alert on
  WebAuthn::Error, and re-registering an existing credential (RecordNotUnique
  on the global unique index) returns a friendly "already registered" instead
  of a 500. Passkey#authenticate rescues the superclass on the assertion path.

- Serialize public keys with public_to_der instead of to_der. Ed25519 keys
  decode to a generic OpenSSL::PKey::PKey with no #to_der, which crashed
  registration for EdDSA credentials; public_to_der works for EC/RSA/Ed25519
  and round-trips back through OpenSSL::PKey.read.

- Widen action_pack_passkeys.sign_count from a signed INT4 to bigint. WebAuthn
  sign counts are unsigned 32-bit; a spec-legal high counter overflowed the
  column with a RangeError and broke the replay/clone check at the boundary.

- Reject malformed ceremony input at the decode boundary as InvalidResponseError
  rather than letting TypeError/NoMethodError escape: client data must be a JSON
  object, the challenge must be a string, tokenBinding must be an object, the
  attestation object must be a CBOR map with binary authData, and COSE key
  coordinates must be byte strings.

- Bound CBOR definite-length arrays/maps by the remaining input and build arrays
  incrementally instead of pre-sizing to a declared count. A five-byte input
  could otherwise name a multi-billion-element array and drive an out-of-memory
  pre-allocation; nested containers could amplify further.

- Validate the actual RSA modulus bit length (not the encoded byte length, which
  a leading-zero pad defeats) and reject degenerate public exponents such as e=1.
Copilot AI balanced review requested due to automatic review settings August 26, 2026 06:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Hardens WebAuthn passkey registration against malformed input, unsupported credentials, serialization failures, and oversized counters.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Changes:

  • Adds unified WebAuthn error handling and friendly registration failures.
  • Strengthens CBOR, attestation, COSE key, and RSA validation.
  • Supports Ed25519 serialization and full uint32 signature counters.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
app/controllers/my/passkeys_controller.rb Handles registration and duplicate errors.
lib/action_pack/passkey.rb Rescues all WebAuthn ceremony errors.
lib/action_pack/web_authn.rb Introduces the common error superclass.
lib/action_pack/web_authn/authenticator/attestation.rb Validates decoded attestation structure.
lib/action_pack/web_authn/authenticator/response.rb Adds client-data type guards.
lib/action_pack/web_authn/cbor_decoder.rb Prevents declared-length preallocation DoS.
lib/action_pack/web_authn/cose_key.rb Strengthens coordinate and RSA validation.
lib/action_pack/web_authn/public_key_credential.rb Uses portable public-key DER serialization.
db/migrate/20260825120000_widen_action_pack_passkeys_sign_count_to_bigint.rb Widens signature counters.
db/schema.rb Records the MySQL schema change.
db/schema_sqlite.rb Records the SQLite schema change.
test/controllers/my/passkeys_controller_test.rb Covers malformed and duplicate registration.
test/lib/action_pack/passkey_test.rb Covers uint32-max counter persistence.
test/lib/action_pack/web_authn/authenticator/attestation_response_test.rb Covers malformed registration responses.
test/lib/action_pack/web_authn/cbor_decoder_test.rb Covers declared-length bounds.
test/lib/action_pack/web_authn/cose_key_test.rb Covers COSE and RSA validation.
test/lib/action_pack/web_authn/public_key_credential_test.rb Covers Ed25519 and EC serialization.
test/lib/action_pack/web_authn_test.rb Verifies the error hierarchy.
test/test_helpers/webauthn_test_helper.rb Supports deterministic duplicate credentials.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread app/controllers/my/passkeys_controller.rb
Comment thread lib/action_pack/web_authn/authenticator/response.rb
Comment thread lib/action_pack/web_authn/authenticator/response.rb
Comment thread lib/action_pack/web_authn/authenticator/attestation.rb
Comment thread lib/action_pack/web_authn/authenticator/attestation.rb
Comment thread lib/action_pack/web_authn/cose_key.rb
Comment thread test/lib/action_pack/web_authn/cose_key_test.rb Outdated

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 58db449087

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@@ -113,7 +128,9 @@ def must_not_be_cross_origin
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject non-object token-binding data

When clientDataJSON supplies tokenBinding as a scalar or array, this guard silently skips validation, allowing an otherwise valid registration or authentication response to pass with schema-invalid token-binding data. The previous dig call failed closed, and this hardening should convert that failure into an InvalidResponseError, not acceptance; add an error when the field is present but is not a hash before checking its status.

Useful? React with 👍 / 👎.

Address review: several fuzzing vectors still escaped as TypeError/NoMethodError
rather than the WebAuthn::Error the controller rescues, so they remained 500s.
Rather than guard each decode site, enforce type at the input chokepoints:

- Response/AttestationResponse/AssertionResponse constructors require the raw
  ceremony inputs (client_data_json, attestation_object, authenticator_data,
  signature) to be strings. Strong parameters permit scalars, so a JSON request
  body could otherwise deliver a number/object and crash on JSON.parse or
  #encoding. This also closes the parallel pre-auth assertion-path crashes.
- CoseKey.decode rejects a decoded COSE value that is not a map (e.g. a CBOR
  null public key) instead of indexing nil.
- AttestationResponse requires attested credential data (the AT flag): a valid
  CBOR map with binary authData but no attested credential would otherwise reach
  persistence with a nil credential id/public key and crash on public_to_der.

Also correct an overstated fixture comment (256 bytes, not 2048).

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0ff1760cb9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# count must not pre-size the backing store. read_length caps the count
# at the remaining bytes, and nested containers still exhaust the input
# (and hit the depth limit) before the array can grow large.
Array.new.tap { |arr| read_length.times { arr << decode } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Cap decoded container elements independently of byte size

An authenticated passkey-registration request can still exhaust a worker by placing a definite-length array of millions of one-byte values (or empty containers) inside the attestation map. Because MAX_SIZE permits 10 MB and read_length only compares the count with the remaining byte count, this loop can build millions of Ruby objects/references—hundreds of MB—before the later authData type check rejects the payload. Add a substantially smaller container-element limit or a protocol-appropriate attestation size limit rather than treating every remaining byte as a safe element.

AGENTS.md reference: AGENTS.md:L6-L9

Useful? React with 👍 / 👎.

Comment on lines +39 to +40
unless attestation_object.is_a?(String)
raise ActionPack::WebAuthn::InvalidResponseError, "Attestation object is missing or malformed"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve predecoded attestation inputs

When a library caller passes an existing Authenticator::Attestation into AttestationResponse, this String-only guard now rejects an input that previously worked: the attestation accessor delegates to Attestation.wrap, whose documented behavior is to return an existing attestation object as-is. This breaks callers that decode once or construct responses for custom attestation verifiers; permit both serialized strings and Attestation instances rather than making the wrapper's supported branch unreachable.

Useful? React with 👍 / 👎.

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