mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

access.test.ts

Language TypeScript9.0 KB226 lines

apps/worker/test/access.test.ts

  1. /**
  2. * Verifying the Cloudflare Access assertion.
  3. *
  4. * This is tested hardest because everything else about who-sees-what depends on
  5. * this function returning the right email, or null. If it can be made to return
  6. * an attacker-chosen address, every other control in the system is decoration,
  7. * the exec briefing included, and that one is specified as hard-enforced.
  8. *
  9. * The classic JWT failures are each covered below rather than assumed away: an
  10. * unsigned `alg: none` token, a token signed by the wrong key, one minted for a
  11. * different Access application, an expired one, and the spoofable email header on
  12. * its own. Every one of them must produce null.
  13. *
  14. * These sign real RS256 tokens with a throwaway keypair and stub only the certs
  15. * endpoint, so the signature path is genuinely exercised.
  16. */
  17. import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
  18. import { __resetKeyCache, identify } from '../src/access.js'
  19. const TEAM = 'meridiandisplay.cloudflareaccess.com'
  20. const AUD = 'aud-tag-for-this-application'
  21. let keys: CryptoKeyPair
  22. let otherKeys: CryptoKeyPair
  23. let jwks: unknown
  24. const b64url = (bytes: ArrayBuffer | Uint8Array): string => {
  25. const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
  26. return btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
  27. }
  28. const enc = (o: unknown) => b64url(new TextEncoder().encode(JSON.stringify(o)))
  29. async function sign(
  30. claims: Record<string, unknown>,
  31. opts: { kid?: string; alg?: string; pair?: CryptoKeyPair } = {},
  32. ): Promise<string> {
  33. const header = { alg: opts.alg ?? 'RS256', kid: opts.kid ?? 'test-key', typ: 'JWT' }
  34. const body = `${enc(header)}.${enc(claims)}`
  35. const sig = await crypto.subtle.sign(
  36. { name: 'RSASSA-PKCS1-v1_5' },
  37. (opts.pair ?? keys).privateKey,
  38. new TextEncoder().encode(body),
  39. )
  40. return `${body}.${b64url(sig)}`
  41. }
  42. const now = () => Math.floor(Date.now() / 1000)
  43. const validClaims = (over: Record<string, unknown> = {}) => ({
  44. aud: [AUD],
  45. email: 'nora.l@meridiandisplay.example',
  46. sub: 'user-123',
  47. iss: `https://${TEAM}`,
  48. exp: now() + 600,
  49. iat: now() - 10,
  50. ...over,
  51. })
  52. const reqWith = (token: string | null, extra: Record<string, string> = {}) =>
  53. new Request('https://board.example/', {
  54. headers: { ...(token ? { 'Cf-Access-Jwt-Assertion': token } : {}), ...extra },
  55. })
  56. beforeAll(async () => {
  57. const params = { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048,
  58. publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' } as const
  59. keys = await crypto.subtle.generateKey(params, true, ['sign', 'verify'])
  60. otherKeys = await crypto.subtle.generateKey(params, true, ['sign', 'verify'])
  61. const pub = await crypto.subtle.exportKey('jwk', keys.publicKey)
  62. jwks = { keys: [{ kid: 'test-key', kty: 'RSA', n: pub.n, e: pub.e, alg: 'RS256' }] }
  63. })
  64. // Every test re-stubs fetch, and the module caches keys for five minutes, so
  65. // the cache is reset between tests by re-importing is unnecessary here because
  66. // the same key set is served throughout.
  67. const stubCerts = (body: unknown = jwks, ok = true) =>
  68. vi.stubGlobal('fetch', vi.fn(async () => ({ ok, status: ok ? 200 : 500, json: async () => body })))
  69. afterEach(() => vi.unstubAllGlobals())
  70. describe('a valid assertion identifies the person', () => {
  71. it('returns the email from a properly signed token', async () => {
  72. stubCerts()
  73. const id = await identify(reqWith(await sign(validClaims())), TEAM, AUD)
  74. expect(id?.email).toBe('nora.l@meridiandisplay.example')
  75. })
  76. it('lowercases and trims, so casing cannot dodge the roster match', async () => {
  77. stubCerts()
  78. const id = await identify(
  79. reqWith(await sign(validClaims({ email: ' NORA.L@MeridianDisplay.example ' }))), TEAM, AUD)
  80. expect(id?.email).toBe('nora.l@meridiandisplay.example')
  81. })
  82. it('accepts a string aud as well as an array', async () => {
  83. stubCerts()
  84. const id = await identify(reqWith(await sign(validClaims({ aud: AUD }))), TEAM, AUD)
  85. expect(id?.email).toBe('nora.l@meridiandisplay.example')
  86. })
  87. })
  88. describe('forged and mismatched assertions are refused', () => {
  89. it('refuses a token signed by a different key', async () => {
  90. // The whole point of verifying rather than decoding.
  91. stubCerts()
  92. const token = await sign(validClaims(), { pair: otherKeys })
  93. expect(await identify(reqWith(token), TEAM, AUD)).toBeNull()
  94. })
  95. it('refuses alg: none, the oldest JWT hole there is', async () => {
  96. stubCerts()
  97. const header = enc({ alg: 'none', kid: 'test-key', typ: 'JWT' })
  98. const token = `${header}.${enc(validClaims())}.`
  99. expect(await identify(reqWith(token), TEAM, AUD)).toBeNull()
  100. })
  101. it('refuses a non-RS256 alg even when the signature is otherwise fine', async () => {
  102. stubCerts()
  103. const token = await sign(validClaims(), { alg: 'HS256' })
  104. expect(await identify(reqWith(token), TEAM, AUD)).toBeNull()
  105. })
  106. it('refuses a token minted for a different Access application', async () => {
  107. // Access teams commonly protect several apps. A token for another one is a
  108. // real, signed token; only the audience says it is not for this board.
  109. stubCerts()
  110. const token = await sign(validClaims({ aud: ['some-other-application'] }))
  111. expect(await identify(reqWith(token), TEAM, AUD)).toBeNull()
  112. })
  113. it('refuses an expired token', async () => {
  114. stubCerts()
  115. expect(await identify(reqWith(await sign(validClaims({ exp: now() - 5 }))), TEAM, AUD)).toBeNull()
  116. })
  117. it('refuses a token that is not valid yet', async () => {
  118. stubCerts()
  119. expect(await identify(reqWith(await sign(validClaims({ nbf: now() + 600 }))), TEAM, AUD)).toBeNull()
  120. })
  121. it('refuses a token from a different Access team', async () => {
  122. stubCerts()
  123. const token = await sign(validClaims({ iss: 'https://someoneelse.cloudflareaccess.com' }))
  124. expect(await identify(reqWith(token), TEAM, AUD)).toBeNull()
  125. })
  126. it('refuses an issuer that merely CONTAINS the team domain', async () => {
  127. // The check was `iss.includes(teamDomain)`, which this passes. Not
  128. // exploitable (the signature still had to verify against our team's keys),
  129. // but a check that only holds because another check is doing the work is not
  130. // a check.
  131. stubCerts()
  132. for (const iss of [
  133. `https://attacker.example/${TEAM}`,
  134. `https://${TEAM}.attacker.example`,
  135. `https://evil-${TEAM}`,
  136. ]) {
  137. expect(await identify(reqWith(await sign(validClaims({ iss }))), TEAM, AUD), iss).toBeNull()
  138. }
  139. })
  140. it('refuses a token whose kid matches no current signing key', async () => {
  141. stubCerts()
  142. expect(await identify(reqWith(await sign(validClaims(), { kid: 'rotated-away' })), TEAM, AUD)).toBeNull()
  143. })
  144. it('refuses a token carrying no email claim', async () => {
  145. stubCerts()
  146. const c = validClaims(); delete (c as Record<string, unknown>).email
  147. expect(await identify(reqWith(await sign(c)), TEAM, AUD)).toBeNull()
  148. })
  149. })
  150. describe('the email header alone proves nothing', () => {
  151. it('refuses a request carrying only Cf-Access-Authenticated-User-Email', async () => {
  152. // The reason this file exists: that header is trivially settable by whoever
  153. // sends the request, and the Worker is on a publicly routable workers.dev
  154. // hostname. Access sits in front by configuration, not by physics.
  155. stubCerts()
  156. const req = reqWith(null, { 'Cf-Access-Authenticated-User-Email': 'nora.l@meridiandisplay.example' })
  157. expect(await identify(req, TEAM, AUD)).toBeNull()
  158. })
  159. it('refuses a request with no assertion at all', async () => {
  160. stubCerts()
  161. expect(await identify(reqWith(null), TEAM, AUD)).toBeNull()
  162. })
  163. it('refuses a malformed assertion', async () => {
  164. stubCerts()
  165. for (const junk of ['', 'not-a-jwt', 'a.b', 'a.b.c.d', '...']) {
  166. expect(await identify(reqWith(junk), TEAM, AUD), junk).toBeNull()
  167. }
  168. })
  169. })
  170. describe('an unconfigured Worker fails closed', () => {
  171. it('refuses everything when the team domain is unset', async () => {
  172. stubCerts()
  173. expect(await identify(reqWith(await sign(validClaims())), undefined, AUD)).toBeNull()
  174. })
  175. it('refuses everything when the audience is unset', async () => {
  176. stubCerts()
  177. expect(await identify(reqWith(await sign(validClaims())), TEAM, undefined)).toBeNull()
  178. })
  179. it('refuses when the certs endpoint cannot be reached', async () => {
  180. // Serving the board to everyone because a key fetch failed would be the worst
  181. // possible failure mode. The cache is cleared first: with keys already warm
  182. // this would pass without ever attempting a fetch, which is the test lying.
  183. __resetKeyCache()
  184. stubCerts({}, false)
  185. expect(await identify(reqWith(await sign(validClaims())), TEAM, AUD)).toBeNull()
  186. })
  187. it('serves from cache rather than fetching keys on every request', async () => {
  188. __resetKeyCache()
  189. const f = vi.fn(async () => ({ ok: true, status: 200, json: async () => jwks }))
  190. vi.stubGlobal('fetch', f)
  191. const token = await sign(validClaims())
  192. await identify(reqWith(token), TEAM, AUD)
  193. await identify(reqWith(token), TEAM, AUD)
  194. expect(f).toHaveBeenCalledTimes(1)
  195. })
  196. })