-
Notifications
You must be signed in to change notification settings - Fork 452
chore(backend): Cache invalid opaque OAuth tokens locally #8519
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
wobsoriano
wants to merge
17
commits into
main
from
rob/aisec-24-unauthenticated-attacker-prevents-customer-backend-from
Closed
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
a7358cd
feat(backend): add CfConnectingIp, ForwardedFor, and RealIp header co…
wobsoriano c0558cc
feat(backend): add MachineTokenRateLimit to AuthErrorReason
wobsoriano af973b7
feat(backend): add per-IP token-bucket rate limiter for machine auth
wobsoriano 7df1869
test(backend): add failing integration tests for machine token rate l…
wobsoriano c67bca3
feat(backend): gate verifyMachineAuthToken behind per-IP token-bucket…
wobsoriano 9530d3a
fix(backend): exempt JWT-format OAuth and M2M tokens from IP rate lim…
wobsoriano ef15a10
chore: add integration tests
wobsoriano 8f7dabf
Merge branch 'main' into rob/aisec-24-unauthenticated-attacker-preven…
wobsoriano 88413ea
chore: revert e2e
wobsoriano 2a1b3a8
chore: address coderabbit comments
wobsoriano eca73d4
chore: limit solution to oauth opaque tokens
wobsoriano 0ed7f13
chore: improve tests
wobsoriano 16a9834
Merge branch 'main' into rob/aisec-24-unauthenticated-attacker-preven…
wobsoriano a0ace65
chore: remove unused added constants
wobsoriano e90b4d4
chore: only cache oauth tokens
wobsoriano 21a2f11
chore: remove useless e2e test
wobsoriano 5a74cb4
chore: use 'any' as mocked option for authenticateRequest
wobsoriano File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
147 changes: 147 additions & 0 deletions
147
packages/backend/src/tokens/__tests__/oauthNegativeCache.test.ts
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { MachineTokenVerificationError, MachineTokenVerificationErrorCode } from '../../errors'; | ||
| import { | ||
| isOAuthTokenCachedAsInvalid, | ||
| makeCachedInvalidOAuthTokenError, | ||
| maybeCacheOAuthTokenAsInvalid, | ||
| resetOAuthNegativeCache, | ||
| } from '../oauthNegativeCache'; | ||
|
|
||
| const TOKEN = 'oat_abc123'; | ||
| const ANOTHER_TOKEN = 'oat_xyz789'; | ||
|
|
||
| function makeTokenInvalidError() { | ||
| return new MachineTokenVerificationError({ | ||
| message: 'OAuth token not found', | ||
| code: MachineTokenVerificationErrorCode.TokenInvalid, | ||
| status: 404, | ||
| }); | ||
| } | ||
|
|
||
| function makeOtherError() { | ||
| return new MachineTokenVerificationError({ | ||
| message: 'Invalid secret key', | ||
| code: MachineTokenVerificationErrorCode.InvalidSecretKey, | ||
| status: 401, | ||
| }); | ||
| } | ||
|
|
||
| describe('oauthNegativeCache', () => { | ||
| beforeEach(() => { | ||
| resetOAuthNegativeCache(); | ||
| vi.useFakeTimers(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers(); | ||
| }); | ||
|
|
||
| describe('isOAuthTokenCachedAsInvalid', () => { | ||
| it('returns false for a token that has never been cached', () => { | ||
| expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false); | ||
| }); | ||
|
|
||
| it('returns true for a token cached as invalid', () => { | ||
| maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN); | ||
| expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(true); | ||
| }); | ||
|
|
||
| it('returns false for a different token not in the cache', () => { | ||
| maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN); | ||
| expect(isOAuthTokenCachedAsInvalid(ANOTHER_TOKEN)).toBe(false); | ||
| }); | ||
|
|
||
| it('returns false and evicts the entry after TTL expires', () => { | ||
| maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN); | ||
| expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(true); | ||
|
|
||
| vi.advanceTimersByTime(30_001); | ||
|
|
||
| expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false); | ||
| }); | ||
|
|
||
| it('returns true just before TTL expires', () => { | ||
| maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN); | ||
| vi.advanceTimersByTime(29_999); | ||
| expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe('maybeCacheOAuthTokenAsInvalid', () => { | ||
| it('caches when error is TokenInvalid', () => { | ||
| maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN); | ||
| expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(true); | ||
| }); | ||
|
|
||
| it('does not cache when error is a different MachineTokenVerificationError code', () => { | ||
| maybeCacheOAuthTokenAsInvalid(makeOtherError(), TOKEN); | ||
| expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false); | ||
| }); | ||
|
|
||
| it('does not cache when error is not a MachineTokenVerificationError', () => { | ||
| maybeCacheOAuthTokenAsInvalid(new Error('network failure'), TOKEN); | ||
| expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false); | ||
| }); | ||
|
|
||
| it('does not cache when error is null', () => { | ||
| maybeCacheOAuthTokenAsInvalid(null, TOKEN); | ||
| expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false); | ||
| }); | ||
|
|
||
| it('updates the expiry when caching the same token again', () => { | ||
| maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN); | ||
|
|
||
| vi.advanceTimersByTime(20_000); | ||
| maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN); | ||
|
|
||
| vi.advanceTimersByTime(20_000); | ||
| // 40s total since first cache, but only 20s since re-cache; should still be valid | ||
| expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(true); | ||
|
|
||
| vi.advanceTimersByTime(10_001); | ||
| // 30s since re-cache; should now expire | ||
| expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe('makeCachedInvalidOAuthTokenError', () => { | ||
| it('returns a MachineTokenVerificationError with TokenInvalid code', () => { | ||
| const err = makeCachedInvalidOAuthTokenError(); | ||
| expect(err).toBeInstanceOf(MachineTokenVerificationError); | ||
| expect(err.code).toBe(MachineTokenVerificationErrorCode.TokenInvalid); | ||
| }); | ||
|
|
||
| it('returns an error with status 404', () => { | ||
| const err = makeCachedInvalidOAuthTokenError(); | ||
| expect(err.status).toBe(404); | ||
| }); | ||
| }); | ||
|
|
||
| describe('resetOAuthNegativeCache', () => { | ||
| it('clears all cached entries', () => { | ||
| maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), TOKEN); | ||
| maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), ANOTHER_TOKEN); | ||
| resetOAuthNegativeCache(); | ||
| expect(isOAuthTokenCachedAsInvalid(TOKEN)).toBe(false); | ||
| expect(isOAuthTokenCachedAsInvalid(ANOTHER_TOKEN)).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe('capacity eviction', () => { | ||
| it('evicts the oldest entry when the cache reaches MAX_ENTRIES (10,000)', () => { | ||
| // Fill the cache to max capacity | ||
| for (let i = 0; i < 10_000; i++) { | ||
| maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), `oat_token_${i}`); | ||
| } | ||
|
|
||
| expect(isOAuthTokenCachedAsInvalid('oat_token_0')).toBe(true); | ||
|
|
||
| // Adding one more should evict oat_token_0 (the oldest) | ||
| maybeCacheOAuthTokenAsInvalid(makeTokenInvalidError(), 'oat_overflow'); | ||
|
|
||
| expect(isOAuthTokenCachedAsInvalid('oat_token_0')).toBe(false); | ||
| expect(isOAuthTokenCachedAsInvalid('oat_overflow')).toBe(true); | ||
| }); | ||
| }); | ||
| }); |
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import { MachineTokenVerificationError, MachineTokenVerificationErrorCode } from '../errors'; | ||
|
|
||
| const TTL_MS = 30_000; | ||
| const MAX_ENTRIES = 10_000; | ||
|
|
||
| type Entry = { expiresAt: number }; | ||
| const cache = new Map<string, Entry>(); | ||
|
|
||
| export function isOAuthTokenCachedAsInvalid(token: string): boolean { | ||
| const entry = cache.get(token); | ||
| if (!entry) { | ||
| return false; | ||
| } | ||
| if (Date.now() >= entry.expiresAt) { | ||
| cache.delete(token); | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| export function maybeCacheOAuthTokenAsInvalid(err: unknown, token: string): void { | ||
| if (!(err instanceof MachineTokenVerificationError) || err.code !== MachineTokenVerificationErrorCode.TokenInvalid) { | ||
| return; | ||
| } | ||
| if (cache.size >= MAX_ENTRIES) { | ||
| const oldest = cache.keys().next().value; | ||
| if (oldest !== undefined) { | ||
| cache.delete(oldest); | ||
| } | ||
| } | ||
| cache.set(token, { expiresAt: Date.now() + TTL_MS }); | ||
| } | ||
|
|
||
| export function makeCachedInvalidOAuthTokenError(): MachineTokenVerificationError { | ||
| return new MachineTokenVerificationError({ | ||
| message: 'OAuth token not found', | ||
| code: MachineTokenVerificationErrorCode.TokenInvalid, | ||
| status: 404, | ||
| }); | ||
| } | ||
|
|
||
| export function resetOAuthNegativeCache(): void { | ||
| cache.clear(); | ||
| } |
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
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.