diff --git a/packages/services/cdn-worker/src/auth.ts b/packages/services/cdn-worker/src/auth.ts index 12489471e..14968ef05 100644 --- a/packages/services/cdn-worker/src/auth.ts +++ b/packages/services/cdn-worker/src/auth.ts @@ -10,16 +10,20 @@ export function byteStringToUint8Array(byteString: string) { return ui; } -export async function isKeyValid(targetId: string, headerKey: string): Promise { - const headerData = byteStringToUint8Array(atob(headerKey)); - const secretKeyData = encoder.encode(KEY_DATA); - const secretKey = await crypto.subtle.importKey( - 'raw', - secretKeyData, - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['verify'], - ); +export type KeyValidator = (targetId: string, headerKey: string) => Promise; - return await crypto.subtle.verify('HMAC', secretKey, headerData, encoder.encode(targetId)); -} +export const createIsKeyValid = + (keyData: string): KeyValidator => + async (targetId: string, headerKey: string): Promise => { + const headerData = byteStringToUint8Array(atob(headerKey)); + const secretKeyData = encoder.encode(keyData); + const secretKey = await crypto.subtle.importKey( + 'raw', + secretKeyData, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'], + ); + + return await crypto.subtle.verify('HMAC', secretKey, headerData, encoder.encode(targetId)); + }; diff --git a/packages/services/cdn-worker/src/dev.ts b/packages/services/cdn-worker/src/dev.ts index ca3338d0d..7bfac9e2c 100644 --- a/packages/services/cdn-worker/src/dev.ts +++ b/packages/services/cdn-worker/src/dev.ts @@ -1,15 +1,32 @@ import './dev-polyfill'; import { createServer } from 'http'; -import { handleRequest } from './handler'; +import { createRequestHandler } from './handler'; import { devStorage } from './dev-polyfill'; -import { isKeyValid } from './auth'; import { createServerAdapter } from '@whatwg-node/server'; import { Router } from 'itty-router'; import { withParams, json } from 'itty-router-extras'; +import { createIsKeyValid } from './auth'; // eslint-disable-next-line no-process-env const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 4010; +/** + * KV Storage for the CDN + */ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +declare let HIVE_DATA: KVNamespace; + +/** + * Secret used to sign the CDN keys + */ +declare let KEY_DATA: string; + +const handleRequest = createRequestHandler({ + getRawStoreValue: value => HIVE_DATA.get(value), + isKeyValid: createIsKeyValid(KEY_DATA), +}); + function main() { const app = createServerAdapter(Router()); @@ -55,7 +72,7 @@ function main() { }), ); - app.get('*', (request: Request) => handleRequest(request, isKeyValid)); + app.get('*', (request: Request) => handleRequest(request)); const server = createServer(app); diff --git a/packages/services/cdn-worker/src/global.d.ts b/packages/services/cdn-worker/src/global.d.ts deleted file mode 100644 index 690bcd305..000000000 --- a/packages/services/cdn-worker/src/global.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -export {}; - -declare global { - /** - * KV Storage for the CDN - */ - let HIVE_DATA: KVNamespace; - /** - * Secret used to sign the CDN keys - */ - let KEY_DATA: string; - let SENTRY_DSN: string; - /** - * Name of the environment, e.g. staging, production - */ - let SENTRY_ENVIRONMENT: string; - /** - * Id of the release - */ - let SENTRY_RELEASE: string; -} diff --git a/packages/services/cdn-worker/src/handler.ts b/packages/services/cdn-worker/src/handler.ts index 4bf44ceac..6674a27f7 100644 --- a/packages/services/cdn-worker/src/handler.ts +++ b/packages/services/cdn-worker/src/handler.ts @@ -6,8 +6,8 @@ import { MissingAuthKey, MissingTargetIDErrorResponse, } from './errors'; -import { isKeyValid } from './auth'; import { buildSchema, introspectionFromSchema } from 'graphql'; +import type { KeyValidator } from './auth'; async function createETag(value: string) { const myText = new TextEncoder().encode(value); @@ -96,7 +96,7 @@ const AUTH_HEADER_NAME = 'x-hive-cdn-key'; async function parseIncomingRequest( request: Request, - keyValidator: typeof isKeyValid, + keyValidator: KeyValidator, ): Promise< | { error: Response } | { @@ -151,46 +151,63 @@ async function parseIncomingRequest( } } -export async function handleRequest(request: Request, keyValidator: typeof isKeyValid) { - const parsedRequest = await parseIncomingRequest(request, keyValidator); +/** + * Handler for verifying whether an access key is valid. + */ +type IsKeyValid = (targetId: string, headerKey: string) => Promise; - if ('error' in parsedRequest) { - return parsedRequest.error; - } +/** + * Read a raw value from the store. + */ +type GetRawStoreValue = (targetId: string) => Promise; - const { targetId, artifactType, storageKeyType } = parsedRequest; - - const kvStorageKey = `target:${targetId}:${storageKeyType}`; - const rawValue = await HIVE_DATA.get(kvStorageKey); - - if (rawValue) { - const etag = await createETag(`${kvStorageKey}|${rawValue}`); - const ifNoneMatch = request.headers.get('if-none-match'); - - if (ifNoneMatch && ifNoneMatch === etag) { - return new Response(null, { status: 304 }); - } - - switch (artifactType) { - case 'schema': - return artifactTypesHandlers.schema(targetId, artifactType, rawValue, etag); - case 'supergraph': - return artifactTypesHandlers.supergraph(targetId, artifactType, rawValue, etag); - case 'sdl': - return artifactTypesHandlers.sdl(targetId, artifactType, rawValue, etag); - case 'introspection': - return artifactTypesHandlers.introspection(targetId, artifactType, rawValue, etag); - case 'metadata': - return artifactTypesHandlers.metadata(targetId, artifactType, rawValue, etag); - default: - return new Response(null, { - status: 500, - }); - } - } else { - console.log( - `CDN Artifact not found for targetId=${targetId}, artifactType=${artifactType}, storageKeyType=${storageKeyType}`, - ); - return new CDNArtifactNotFound(artifactType, targetId); - } +interface RequestHandlerDependencies { + isKeyValid: IsKeyValid; + getRawStoreValue: GetRawStoreValue; } + +export const createRequestHandler = + (deps: RequestHandlerDependencies) => + async (request: Request): Promise => { + const parsedRequest = await parseIncomingRequest(request, deps.isKeyValid); + + if ('error' in parsedRequest) { + return parsedRequest.error; + } + + const { targetId, artifactType, storageKeyType } = parsedRequest; + + const kvStorageKey = `target:${targetId}:${storageKeyType}`; + const rawValue = await deps.getRawStoreValue(kvStorageKey); + + if (rawValue) { + const etag = await createETag(`${kvStorageKey}|${rawValue}`); + const ifNoneMatch = request.headers.get('if-none-match'); + + if (ifNoneMatch && ifNoneMatch === etag) { + return new Response(null, { status: 304 }); + } + + switch (artifactType) { + case 'schema': + return artifactTypesHandlers.schema(targetId, artifactType, rawValue, etag); + case 'supergraph': + return artifactTypesHandlers.supergraph(targetId, artifactType, rawValue, etag); + case 'sdl': + return artifactTypesHandlers.sdl(targetId, artifactType, rawValue, etag); + case 'introspection': + return artifactTypesHandlers.introspection(targetId, artifactType, rawValue, etag); + case 'metadata': + return artifactTypesHandlers.metadata(targetId, artifactType, rawValue, etag); + default: + return new Response(null, { + status: 500, + }); + } + } else { + console.log( + `CDN Artifact not found for targetId=${targetId}, artifactType=${artifactType}, storageKeyType=${storageKeyType}`, + ); + return new CDNArtifactNotFound(artifactType, targetId); + } + }; diff --git a/packages/services/cdn-worker/src/index.ts b/packages/services/cdn-worker/src/index.ts index f3972f90c..bc7d7dbb5 100644 --- a/packages/services/cdn-worker/src/index.ts +++ b/packages/services/cdn-worker/src/index.ts @@ -1,11 +1,36 @@ import Toucan from 'toucan-js'; -import { isKeyValid } from './auth'; +import { createIsKeyValid } from './auth'; import { UnexpectedError } from './errors'; -import { handleRequest } from './handler'; +import { createRequestHandler } from './handler'; + +/** + * KV Storage for the CDN + */ +declare let HIVE_DATA: KVNamespace; + +/** + * Secret used to sign the CDN keys + */ +declare let KEY_DATA: string; + +declare let SENTRY_DSN: string; +/** + * Name of the environment, e.g. staging, production + */ +declare let SENTRY_ENVIRONMENT: string; +/** + * Id of the release + */ +declare let SENTRY_RELEASE: string; + +const handleRequest = createRequestHandler({ + getRawStoreValue: value => HIVE_DATA.get(value), + isKeyValid: createIsKeyValid(KEY_DATA), +}); self.addEventListener('fetch', event => { try { - event.respondWith(handleRequest(event.request, isKeyValid)); + event.respondWith(handleRequest(event.request)); } catch (error) { const sentry = new Toucan({ dsn: SENTRY_DSN, diff --git a/packages/services/cdn-worker/tests/cdn.spec.ts b/packages/services/cdn-worker/tests/cdn.spec.ts index 93a60873e..f68dbfe22 100644 --- a/packages/services/cdn-worker/tests/cdn.spec.ts +++ b/packages/services/cdn-worker/tests/cdn.spec.ts @@ -1,43 +1,20 @@ import '../src/dev-polyfill'; -import { handleRequest } from '../src/handler'; +import { createRequestHandler } from '../src/handler'; import { InvalidArtifactTypeResponse, InvalidAuthKey, MissingAuthKey, MissingTargetIDErrorResponse, } from '../src/errors'; -import { isKeyValid } from '../src/auth'; +import { createIsKeyValid, KeyValidator } from '../src/auth'; import { createHmac } from 'crypto'; describe('CDN Worker', () => { - const KeyValidators: Record = { + const KeyValidators: Record = { AlwaysTrue: () => Promise.resolve(true), AlwaysFalse: () => Promise.resolve(false), - Bcrypt: isKeyValid, }; - function mockWorkerEnv(input: { HIVE_DATA: Map; KEY_DATA: string }) { - Object.defineProperties(globalThis, { - HIVE_DATA: { - value: input.HIVE_DATA, - }, - KEY_DATA: { - value: input.KEY_DATA, - }, - }); - } - - function clearWorkerEnv() { - Object.defineProperties(globalThis, { - HIVE_DATA: { - value: undefined, - }, - KEY_DATA: { - value: undefined, - }, - }); - } - function createToken(secret: string, targetId: string): string { const encoder = new TextEncoder(); const secretKeyData = encoder.encode(secret); @@ -45,17 +22,15 @@ describe('CDN Worker', () => { return createHmac('sha256', secretKeyData).update(encoder.encode(targetId)).digest('base64'); } - afterEach(clearWorkerEnv); - test('in /schema and /metadata the response should contain content-type: application/json header', async () => { const SECRET = '123456'; const targetId = 'fake-target-id'; const map = new Map(); map.set(`target:${targetId}:schema`, JSON.stringify({ sdl: `type Query { dummy: String }` })); - mockWorkerEnv({ - HIVE_DATA: map, - KEY_DATA: SECRET, + const handleRequest = createRequestHandler({ + isKeyValid: createIsKeyValid(SECRET), + getRawStoreValue: (key: string) => map.get(key), }); const token = createToken(SECRET, targetId); @@ -66,7 +41,7 @@ describe('CDN Worker', () => { }, }); - const schemaResponse = await handleRequest(schemaRequest, KeyValidators.Bcrypt); + const schemaResponse = await handleRequest(schemaRequest); expect(schemaResponse.status).toBe(200); expect(schemaResponse.headers.get('content-type')).toBe('application/json'); @@ -76,7 +51,7 @@ describe('CDN Worker', () => { }, }); - const metadataResponse = await handleRequest(metadataRequest, KeyValidators.Bcrypt); + const metadataResponse = await handleRequest(metadataRequest); expect(metadataResponse.status).toBe(200); expect(metadataResponse.headers.get('content-type')).toBe('application/json'); }); @@ -87,9 +62,9 @@ describe('CDN Worker', () => { const map = new Map(); map.set(`target:${targetId}:schema`, JSON.stringify({ sdl: `type Query { dummy: String }` })); - mockWorkerEnv({ - HIVE_DATA: map, - KEY_DATA: SECRET, + const handleRequest = createRequestHandler({ + isKeyValid: createIsKeyValid(SECRET), + getRawStoreValue: (key: string) => map.get(key), }); const token = createToken(SECRET, targetId); @@ -99,7 +74,7 @@ describe('CDN Worker', () => { 'x-hive-cdn-key': token, }, }); - const firstResponse = await handleRequest(firstRequest, KeyValidators.Bcrypt); + const firstResponse = await handleRequest(firstRequest); const etag = firstResponse.headers.get('etag'); expect(firstResponse.status).toBe(200); @@ -114,7 +89,7 @@ describe('CDN Worker', () => { 'if-none-match': etag!, }, }); - const secondResponse = await handleRequest(secondRequest, KeyValidators.Bcrypt); + const secondResponse = await handleRequest(secondRequest); expect(secondResponse.status).toBe(304); expect(secondResponse.body).toBeNull(); @@ -125,7 +100,7 @@ describe('CDN Worker', () => { 'if-none-match': '"non-existing-etag"', }, }); - const wrongEtagResponse = await handleRequest(wrongEtagRequest, KeyValidators.Bcrypt); + const wrongEtagResponse = await handleRequest(wrongEtagRequest); expect(wrongEtagResponse.status).toBe(200); expect(wrongEtagResponse.body).toBeDefined(); }); @@ -139,9 +114,9 @@ describe('CDN Worker', () => { JSON.stringify({ sdl: `type Query { dummy: String }` }), ); - mockWorkerEnv({ - HIVE_DATA: map, - KEY_DATA: SECRET, + const handleRequest = createRequestHandler({ + isKeyValid: createIsKeyValid(SECRET), + getRawStoreValue: (key: string) => map.get(key), }); const token = createToken(SECRET, targetId); @@ -151,7 +126,7 @@ describe('CDN Worker', () => { 'x-hive-cdn-key': token, }, }); - const firstResponse = await handleRequest(firstRequest, KeyValidators.Bcrypt); + const firstResponse = await handleRequest(firstRequest); const etag = firstResponse.headers.get('etag'); expect(firstResponse.status).toBe(200); @@ -166,7 +141,7 @@ describe('CDN Worker', () => { 'if-none-match': etag!, }, }); - const secondResponse = await handleRequest(secondRequest, KeyValidators.Bcrypt); + const secondResponse = await handleRequest(secondRequest); expect(secondResponse.status).toBe(304); expect(secondResponse.body).toBeNull(); @@ -177,7 +152,7 @@ describe('CDN Worker', () => { 'if-none-match': '"non-existing-etag"', }, }); - const wrongEtagResponse = await handleRequest(wrongEtagRequest, KeyValidators.Bcrypt); + const wrongEtagResponse = await handleRequest(wrongEtagRequest); expect(wrongEtagResponse.status).toBe(200); expect(wrongEtagResponse.body).toBeDefined(); }); @@ -188,9 +163,9 @@ describe('CDN Worker', () => { const map = new Map(); map.set(`target:${targetId}:metadata`, JSON.stringify({ sdl: `type Query { dummy: String }` })); - mockWorkerEnv({ - HIVE_DATA: map, - KEY_DATA: SECRET, + const handleRequest = createRequestHandler({ + isKeyValid: createIsKeyValid(SECRET), + getRawStoreValue: (key: string) => map.get(key), }); const token = createToken(SECRET, targetId); @@ -200,7 +175,7 @@ describe('CDN Worker', () => { 'x-hive-cdn-key': token, }, }); - const firstResponse = await handleRequest(firstRequest, KeyValidators.Bcrypt); + const firstResponse = await handleRequest(firstRequest); const etag = firstResponse.headers.get('etag'); expect(firstResponse.status).toBe(200); @@ -215,7 +190,7 @@ describe('CDN Worker', () => { 'if-none-match': etag!, }, }); - const secondResponse = await handleRequest(secondRequest, KeyValidators.Bcrypt); + const secondResponse = await handleRequest(secondRequest); expect(secondResponse.status).toBe(304); expect(secondResponse.body).toBeNull(); @@ -226,7 +201,7 @@ describe('CDN Worker', () => { 'if-none-match': '"non-existing-etag"', }, }); - const wrongEtagResponse = await handleRequest(wrongEtagRequest, KeyValidators.Bcrypt); + const wrongEtagResponse = await handleRequest(wrongEtagRequest); expect(wrongEtagResponse.status).toBe(200); expect(wrongEtagResponse.body).toBeDefined(); }); @@ -237,9 +212,9 @@ describe('CDN Worker', () => { const map = new Map(); map.set(`target:${targetId}:schema`, JSON.stringify({ sdl: `type Query { dummy: String }` })); - mockWorkerEnv({ - HIVE_DATA: map, - KEY_DATA: SECRET, + const handleRequest = createRequestHandler({ + isKeyValid: createIsKeyValid(SECRET), + getRawStoreValue: (key: string) => map.get(key), }); const token = createToken(SECRET, targetId); @@ -249,7 +224,7 @@ describe('CDN Worker', () => { 'x-hive-cdn-key': token, }, }); - const firstResponse = await handleRequest(firstRequest, KeyValidators.Bcrypt); + const firstResponse = await handleRequest(firstRequest); const etag = firstResponse.headers.get('etag'); expect(firstResponse.status).toBe(200); @@ -264,7 +239,7 @@ describe('CDN Worker', () => { 'if-none-match': etag!, }, }); - const secondResponse = await handleRequest(secondRequest, KeyValidators.Bcrypt); + const secondResponse = await handleRequest(secondRequest); expect(secondResponse.status).toBe(304); expect(secondResponse.body).toBeNull(); @@ -275,7 +250,7 @@ describe('CDN Worker', () => { 'if-none-match': '"non-existing-etag"', }, }); - const wrongEtagResponse = await handleRequest(wrongEtagRequest, KeyValidators.Bcrypt); + const wrongEtagResponse = await handleRequest(wrongEtagRequest); expect(wrongEtagResponse.status).toBe(200); expect(wrongEtagResponse.body).toBeDefined(); }); @@ -291,9 +266,9 @@ describe('CDN Worker', () => { }), ); - mockWorkerEnv({ - HIVE_DATA: map, - KEY_DATA: SECRET, + const handleRequest = createRequestHandler({ + isKeyValid: createIsKeyValid(SECRET), + getRawStoreValue: (key: string) => map.get(key), }); const token = createToken(SECRET, targetId); @@ -303,7 +278,7 @@ describe('CDN Worker', () => { 'x-hive-cdn-key': token, }, }); - const firstResponse = await handleRequest(firstRequest, KeyValidators.Bcrypt); + const firstResponse = await handleRequest(firstRequest); const etag = firstResponse.headers.get('etag'); expect(firstResponse.status).toBe(200); @@ -318,7 +293,7 @@ describe('CDN Worker', () => { 'if-none-match': etag!, }, }); - const secondResponse = await handleRequest(secondRequest, KeyValidators.Bcrypt); + const secondResponse = await handleRequest(secondRequest); expect(secondResponse.status).toBe(304); expect(secondResponse.body).toBeNull(); @@ -329,55 +304,55 @@ describe('CDN Worker', () => { 'if-none-match': '"non-existing-etag"', }, }); - const wrongEtagResponse = await handleRequest(wrongEtagRequest, KeyValidators.Bcrypt); + const wrongEtagResponse = await handleRequest(wrongEtagRequest); expect(wrongEtagResponse.status).toBe(200); expect(wrongEtagResponse.body).toBeDefined(); }); describe('Basic parsing errors', () => { it('Should throw when target id is missing', async () => { - mockWorkerEnv({ - HIVE_DATA: new Map(), - KEY_DATA: '', + const handleRequest = createRequestHandler({ + isKeyValid: KeyValidators.AlwaysTrue, + getRawStoreValue: (_key: string) => Promise.resolve(null), }); const request = new Request('https://fake-worker.com/', {}); - const response = await handleRequest(request, KeyValidators.AlwaysTrue); + const response = await handleRequest(request); expect(response instanceof MissingTargetIDErrorResponse).toBeTruthy(); expect(response.status).toBe(400); }); it('Should throw when requested resource is not valid', async () => { - mockWorkerEnv({ - HIVE_DATA: new Map(), - KEY_DATA: '', + const handleRequest = createRequestHandler({ + isKeyValid: KeyValidators.AlwaysTrue, + getRawStoreValue: (_key: string) => Promise.resolve(null), }); const request = new Request('https://fake-worker.com/fake-target-id/error', {}); - const response = await handleRequest(request, KeyValidators.AlwaysTrue); + const response = await handleRequest(request); expect(response instanceof InvalidArtifactTypeResponse).toBeTruthy(); expect(response.status).toBe(400); }); it('Should throw when auth key is missing', async () => { - mockWorkerEnv({ - HIVE_DATA: new Map(), - KEY_DATA: '', + const handleRequest = createRequestHandler({ + isKeyValid: KeyValidators.AlwaysTrue, + getRawStoreValue: (_key: string) => Promise.resolve(null), }); const request = new Request('https://fake-worker.com/fake-target-id/sdl', {}); - const response = await handleRequest(request, KeyValidators.AlwaysTrue); + const response = await handleRequest(request); expect(response instanceof MissingAuthKey).toBeTruthy(); expect(response.status).toBe(400); }); it('Should throw when key validation function fails', async () => { - mockWorkerEnv({ - HIVE_DATA: new Map(), - KEY_DATA: '', + const handleRequest = createRequestHandler({ + isKeyValid: KeyValidators.AlwaysFalse, + getRawStoreValue: (_key: string) => Promise.resolve(null), }); const request = new Request('https://fake-worker.com/fake-target-id/sdl', { @@ -386,7 +361,7 @@ describe('CDN Worker', () => { }, }); - const response = await handleRequest(request, KeyValidators.AlwaysFalse); + const response = await handleRequest(request); expect(response instanceof InvalidAuthKey).toBeTruthy(); expect(response.status).toBe(403); }); @@ -399,9 +374,9 @@ describe('CDN Worker', () => { const map = new Map(); map.set(`target:${targetId}:schema`, JSON.stringify({ sdl: `type Query { dummy: String }` })); - mockWorkerEnv({ - HIVE_DATA: map, - KEY_DATA: SECRET, + const handleRequest = createRequestHandler({ + isKeyValid: createIsKeyValid(SECRET), + getRawStoreValue: (key: string) => map.get(key), }); const token = createToken(SECRET, targetId); @@ -412,16 +387,17 @@ describe('CDN Worker', () => { }, }); - const response = await handleRequest(request, KeyValidators.Bcrypt); + const response = await handleRequest(request); expect(response.status).toBe(200); }); it('Should throw on mismatch of token target and actual target', async () => { const SECRET = '123456'; + const map = new Map(); - mockWorkerEnv({ - HIVE_DATA: new Map(), - KEY_DATA: SECRET, + const handleRequest = createRequestHandler({ + isKeyValid: createIsKeyValid(SECRET), + getRawStoreValue: (key: string) => map.get(key), }); const token = createToken(SECRET, 'fake-target-id'); @@ -432,15 +408,15 @@ describe('CDN Worker', () => { }, }); - const response = await handleRequest(request, KeyValidators.Bcrypt); + const response = await handleRequest(request); expect(response instanceof InvalidAuthKey).toBeTruthy(); expect(response.status).toBe(403); }); it('Should throw on invalid token hash', async () => { - mockWorkerEnv({ - HIVE_DATA: new Map(), - KEY_DATA: '123456', + const handleRequest = createRequestHandler({ + isKeyValid: createIsKeyValid('123456'), + getRawStoreValue: (key: string) => new Map().get(key), }); const request = new Request(`https://fake-worker.com/some-target/sdl`, { @@ -449,7 +425,7 @@ describe('CDN Worker', () => { }, }); - const response = await handleRequest(request, KeyValidators.Bcrypt); + const response = await handleRequest(request); expect(response instanceof InvalidAuthKey).toBeTruthy(); expect(response.status).toBe(403); });