Skip to content

Validate the OAuth state timestamp instead of relying on a loose comparison - #82

Merged
diwenne merged 1 commit into
diwenne:mainfrom
Davidowi2:fix/oauth-state-timestamp-validation
Oct 5, 2026
Merged

diwenne merged 1 commit into
diwenne:mainfrom
Davidowi2:fix/oauth-state-timestamp-validation

Conversation

@Davidowi2

Copy link
Copy Markdown

verifyOAuthState only checked the upper bound of the state age:

if (!parsed.workspaceId || Date.now() - parsed.ts > STATE_MAX_AGE_MS) {

Two payload shapes slip past that check.

A non-numeric ts makes Date.now() - parsed.ts evaluate to NaN, and every comparison against NaN is false, so a state carrying ts: "not-a-number" is accepted and never expires. A ts in the future produces a negative age, which is also never greater than the maximum, so that state is accepted for the same reason.

workspaceId was checked for truthiness only, so a number, boolean, array or object was accepted and returned as OAuthStatePayload, whose type declares it as a string.

This validates that the timestamp is a finite number, rejects an age outside [0, STATE_MAX_AGE_MS], and requires workspaceId to be a non-empty string.

The signature check is unchanged: every case here still needs a valid HMAC, so this is input validation on already-authenticated data rather than a bypass.

Verification

  • npm test — 314 passed (309 before, plus 5 new)
  • npm run lint — clean
  • Four of the five new tests fail against the previous implementation and pass against this one

Pre-existing failure

npm run typecheck and npm run build both fail on main as well, on the same unrelated line:

./lib/queue/dm-worker.ts:855
  operationId: operationId ? `${operationId}:ack` : null,
Type 'string | null' is not assignable to type 'string'.

That is untouched by this change — I confirmed it by stashing this branch and re-running both commands on a clean main.

@vercel

vercel Bot commented Oct 2, 2026

Copy link
Copy Markdown

@Davidowi2 is attempting to deploy a commit to the diwenne's projects Team on Vercel.

A member of the Team first needs to authorize it.

Comment thread lib/meta/oauth.ts
return null;
}

// Reject a missing or non-numeric timestamp. `Date.now() - "abc"` is NaN,

@Davidowi2 Davidowi2 Oct 2, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Date.now() - "not-a-number" is NaN, and every comparison against NaN is false, so a non-numeric ts slipped through and never expired.

Comment thread lib/meta/oauth.ts
// rejected for the same reason: it has not aged at all.
if (typeof parsed.ts !== "number" || !Number.isFinite(parsed.ts)) {
return null;
}

@Davidowi2 Davidowi2 Oct 2, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

A ts in the future gives a negative age, which also passes a > MAX check. Same hole, other direction.

Comment thread __tests__/oauth.test.ts
expect(verifyOAuthState(`${state}tampered`)).toBeNull();
});

// Builds a state whose signature is valid but whose payload has an

@Davidowi2 Davidowi2 Oct 2, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Valid signature on purpose, so each case hits the payload checks instead of stopping at the HMAC.

…arison

verifyOAuthState only checked the upper bound of the state age:

    if (!parsed.workspaceId || Date.now() - parsed.ts > STATE_MAX_AGE_MS) {

Two payload shapes slip past that check.

A non-numeric `ts` makes `Date.now() - parsed.ts` evaluate to NaN, and every
comparison against NaN is false, so a state carrying `ts: "not-a-number"` is
accepted and never expires. A `ts` in the future produces a negative age,
which is also never greater than the maximum, so that state is accepted for
the same reason.

`workspaceId` was checked for truthiness only, so a number, boolean, array or
object was accepted and returned as `OAuthStatePayload`, whose type declares
it as a string.

This validates the timestamp is a finite number, rejects an age outside
[0, STATE_MAX_AGE_MS], and requires workspaceId to be a non-empty string.

The signature check is unchanged: every case here still needs a valid HMAC.
@Davidowi2
Davidowi2 force-pushed the fix/oauth-state-timestamp-validation branch from 30aefe8 to 5860878 Compare October 2, 2026 17:20
@diwenne
diwenne merged commit ff4f9a5 into diwenne:main Oct 5, 2026
1 check failed
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