fix(server): reject timezone-naive expires instead of raising - #209
Open
mehmetkr-31 wants to merge 1 commit into
Open
fix(server): reject timezone-naive expires instead of raising#209mehmetkr-31 wants to merge 1 commit into
mehmetkr-31 wants to merge 1 commit into
Conversation
`verify_or_challenge` parses the echoed `expires` and compares it to `datetime.now(UTC)`. A value without an offset parses successfully but is naive, so the comparison raises `TypeError: can't compare offset-naive and offset-aware datetimes`. The surrounding code is documented to fail closed — "credentials without an expires field or with an unparseable value are rejected outright" — but a naive value is parseable, so it escaped that path. An expired credential then surfaced as a server error instead of a rejection, and every request to the route failed the same way. `expires` is covered by the challenge-id HMAC, so a client cannot inject this; it is reachable when an application passes its own `expires` string, for example `datetime.now().isoformat()` without a timezone. Reject a naive value like any other unusable `expires`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mehmetkr-31
force-pushed
the
fix/verify-naive-expires
branch
from
August 9, 2026 09:15
c1a044c to
7385ab6
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
verify_or_challengeparses the echoedexpiresand compares it todatetime.now(UTC):A timestamp without an offset parses successfully, so it never reaches the
except, and the comparison then raises:The comment directly above this block states the intent — "Credentials without an expires field or with an unparseable value are rejected outright" — but a naive value is parseable, so it escapes the fail-closed path. An expired credential surfaces as a server error instead of a rejection, and because the value is fixed for the route, every subsequent request fails the same way.
Reachability
expiresis covered by the challenge-id HMAC, so a client cannot inject a naive value into someone else's challenge; forging one fails the id check earlier. It is reachable when an application supplies its ownexpirestoverify_or_challenge, for exampledatetime.now().isoformat()instead ofdatetime.now(UTC). The existing runtime guard in_create_challengeonly checks thatexpiresis astr, not that it names an instant.So this is a robustness / fail-closed-contract bug rather than a way for a payer to bypass expiry — I did not want to overstate it.
Reproduced against
mainwithmake_bound_credential(..., expires="2000-01-01T00:00:00"):TypeErroratverify.py:209, where aChallengerejection is expected.Fix
Reject a naive
expiresthe same way an unparseable one is rejected, so the route still fails closed instead of erroring.Tests
Parametrized over a future and a past naive timestamp — the past case is the one that should have been a clean expiry rejection. Both fail on
mainwith theTypeErrorabove and pass here.uv run pytest→ 802 passed, 41 skipped.ruff checkandruff format --checkclean.Open question
Should
_create_challengealso validate an application-suppliedexpiresat issue time, so a naive value is caught when the challenge is minted rather than when a credential comes back? That would fail earlier and more visibly, but it changes the challenge-creation contract, so I kept this PR to the crash itself. Happy to follow up if you want that guard.Disclosure: I used an AI assistant while investigating and preparing this change; the analysis and conclusions are my own to defend.
🤖 Generated with Claude Code