access.test.ts
Language TypeScript9.0 KB226 lines
/*** Verifying the Cloudflare Access assertion.** This is tested hardest because everything else about who-sees-what depends on* this function returning the right email, or null. If it can be made to return* an attacker-chosen address, every other control in the system is decoration,* the exec briefing included, and that one is specified as hard-enforced.** The classic JWT failures are each covered below rather than assumed away: an* unsigned `alg: none` token, a token signed by the wrong key, one minted for a* different Access application, an expired one, and the spoofable email header on* its own. Every one of them must produce null.** These sign real RS256 tokens with a throwaway keypair and stub only the certs* endpoint, so the signature path is genuinely exercised.*/import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'import { __resetKeyCache, identify } from '../src/access.js'const TEAM = 'meridiandisplay.cloudflareaccess.com'const AUD = 'aud-tag-for-this-application'let keys: CryptoKeyPairlet otherKeys: CryptoKeyPairlet jwks: unknownconst b64url = (bytes: ArrayBuffer | Uint8Array): string => {const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)return btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}const enc = (o: unknown) => b64url(new TextEncoder().encode(JSON.stringify(o)))async function sign(claims: Record<string, unknown>,opts: { kid?: string; alg?: string; pair?: CryptoKeyPair } = {},): Promise<string> {const header = { alg: opts.alg ?? 'RS256', kid: opts.kid ?? 'test-key', typ: 'JWT' }const body = `${enc(header)}.${enc(claims)}`const sig = await crypto.subtle.sign({ name: 'RSASSA-PKCS1-v1_5' },(opts.pair ?? keys).privateKey,new TextEncoder().encode(body),)return `${body}.${b64url(sig)}`}const now = () => Math.floor(Date.now() / 1000)const validClaims = (over: Record<string, unknown> = {}) => ({aud: [AUD],email: 'nora.l@meridiandisplay.example',sub: 'user-123',iss: `https://${TEAM}`,exp: now() + 600,iat: now() - 10,...over,})const reqWith = (token: string | null, extra: Record<string, string> = {}) =>new Request('https://board.example/', {headers: { ...(token ? { 'Cf-Access-Jwt-Assertion': token } : {}), ...extra },})beforeAll(async () => {const params = { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048,publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' } as constkeys = await crypto.subtle.generateKey(params, true, ['sign', 'verify'])otherKeys = await crypto.subtle.generateKey(params, true, ['sign', 'verify'])const pub = await crypto.subtle.exportKey('jwk', keys.publicKey)jwks = { keys: [{ kid: 'test-key', kty: 'RSA', n: pub.n, e: pub.e, alg: 'RS256' }] }})// Every test re-stubs fetch, and the module caches keys for five minutes, so// the cache is reset between tests by re-importing is unnecessary here because// the same key set is served throughout.const stubCerts = (body: unknown = jwks, ok = true) =>vi.stubGlobal('fetch', vi.fn(async () => ({ ok, status: ok ? 200 : 500, json: async () => body })))afterEach(() => vi.unstubAllGlobals())describe('a valid assertion identifies the person', () => {it('returns the email from a properly signed token', async () => {stubCerts()const id = await identify(reqWith(await sign(validClaims())), TEAM, AUD)expect(id?.email).toBe('nora.l@meridiandisplay.example')})it('lowercases and trims, so casing cannot dodge the roster match', async () => {stubCerts()const id = await identify(reqWith(await sign(validClaims({ email: ' NORA.L@MeridianDisplay.example ' }))), TEAM, AUD)expect(id?.email).toBe('nora.l@meridiandisplay.example')})it('accepts a string aud as well as an array', async () => {stubCerts()const id = await identify(reqWith(await sign(validClaims({ aud: AUD }))), TEAM, AUD)expect(id?.email).toBe('nora.l@meridiandisplay.example')})})describe('forged and mismatched assertions are refused', () => {it('refuses a token signed by a different key', async () => {// The whole point of verifying rather than decoding.stubCerts()const token = await sign(validClaims(), { pair: otherKeys })expect(await identify(reqWith(token), TEAM, AUD)).toBeNull()})it('refuses alg: none, the oldest JWT hole there is', async () => {stubCerts()const header = enc({ alg: 'none', kid: 'test-key', typ: 'JWT' })const token = `${header}.${enc(validClaims())}.`expect(await identify(reqWith(token), TEAM, AUD)).toBeNull()})it('refuses a non-RS256 alg even when the signature is otherwise fine', async () => {stubCerts()const token = await sign(validClaims(), { alg: 'HS256' })expect(await identify(reqWith(token), TEAM, AUD)).toBeNull()})it('refuses a token minted for a different Access application', async () => {// Access teams commonly protect several apps. A token for another one is a// real, signed token; only the audience says it is not for this board.stubCerts()const token = await sign(validClaims({ aud: ['some-other-application'] }))expect(await identify(reqWith(token), TEAM, AUD)).toBeNull()})it('refuses an expired token', async () => {stubCerts()expect(await identify(reqWith(await sign(validClaims({ exp: now() - 5 }))), TEAM, AUD)).toBeNull()})it('refuses a token that is not valid yet', async () => {stubCerts()expect(await identify(reqWith(await sign(validClaims({ nbf: now() + 600 }))), TEAM, AUD)).toBeNull()})it('refuses a token from a different Access team', async () => {stubCerts()const token = await sign(validClaims({ iss: 'https://someoneelse.cloudflareaccess.com' }))expect(await identify(reqWith(token), TEAM, AUD)).toBeNull()})it('refuses an issuer that merely CONTAINS the team domain', async () => {// The check was `iss.includes(teamDomain)`, which this passes. Not// exploitable (the signature still had to verify against our team's keys),// but a check that only holds because another check is doing the work is not// a check.stubCerts()for (const iss of [`https://attacker.example/${TEAM}`,`https://${TEAM}.attacker.example`,`https://evil-${TEAM}`,]) {expect(await identify(reqWith(await sign(validClaims({ iss }))), TEAM, AUD), iss).toBeNull()}})it('refuses a token whose kid matches no current signing key', async () => {stubCerts()expect(await identify(reqWith(await sign(validClaims(), { kid: 'rotated-away' })), TEAM, AUD)).toBeNull()})it('refuses a token carrying no email claim', async () => {stubCerts()const c = validClaims(); delete (c as Record<string, unknown>).emailexpect(await identify(reqWith(await sign(c)), TEAM, AUD)).toBeNull()})})describe('the email header alone proves nothing', () => {it('refuses a request carrying only Cf-Access-Authenticated-User-Email', async () => {// The reason this file exists: that header is trivially settable by whoever// sends the request, and the Worker is on a publicly routable workers.dev// hostname. Access sits in front by configuration, not by physics.stubCerts()const req = reqWith(null, { 'Cf-Access-Authenticated-User-Email': 'nora.l@meridiandisplay.example' })expect(await identify(req, TEAM, AUD)).toBeNull()})it('refuses a request with no assertion at all', async () => {stubCerts()expect(await identify(reqWith(null), TEAM, AUD)).toBeNull()})it('refuses a malformed assertion', async () => {stubCerts()for (const junk of ['', 'not-a-jwt', 'a.b', 'a.b.c.d', '...']) {expect(await identify(reqWith(junk), TEAM, AUD), junk).toBeNull()}})})describe('an unconfigured Worker fails closed', () => {it('refuses everything when the team domain is unset', async () => {stubCerts()expect(await identify(reqWith(await sign(validClaims())), undefined, AUD)).toBeNull()})it('refuses everything when the audience is unset', async () => {stubCerts()expect(await identify(reqWith(await sign(validClaims())), TEAM, undefined)).toBeNull()})it('refuses when the certs endpoint cannot be reached', async () => {// Serving the board to everyone because a key fetch failed would be the worst// possible failure mode. The cache is cleared first: with keys already warm// this would pass without ever attempting a fetch, which is the test lying.__resetKeyCache()stubCerts({}, false)expect(await identify(reqWith(await sign(validClaims())), TEAM, AUD)).toBeNull()})it('serves from cache rather than fetching keys on every request', async () => {__resetKeyCache()const f = vi.fn(async () => ({ ok: true, status: 200, json: async () => jwks }))vi.stubGlobal('fetch', f)const token = await sign(validClaims())await identify(reqWith(token), TEAM, AUD)await identify(reqWith(token), TEAM, AUD)expect(f).toHaveBeenCalledTimes(1)})})