refactor request handler to not depend upon cloudflare globals (#616)

This commit is contained in:
Laurin Quast 2022-11-25 09:58:45 +01:00 committed by GitHub
parent d1ae8d02cf
commit 9ede925416
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 190 additions and 172 deletions

View file

@ -10,16 +10,20 @@ export function byteStringToUint8Array(byteString: string) {
return ui;
}
export async function isKeyValid(targetId: string, headerKey: string): Promise<boolean> {
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<boolean>;
return await crypto.subtle.verify('HMAC', secretKey, headerData, encoder.encode(targetId));
}
export const createIsKeyValid =
(keyData: string): KeyValidator =>
async (targetId: string, headerKey: string): Promise<boolean> => {
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));
};

View file

@ -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);

View file

@ -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;
}

View file

@ -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<boolean>;
if ('error' in parsedRequest) {
return parsedRequest.error;
}
/**
* Read a raw value from the store.
*/
type GetRawStoreValue = (targetId: string) => Promise<string | null>;
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<Response> => {
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);
}
};

View file

@ -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,

View file

@ -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<string, typeof isKeyValid> = {
const KeyValidators: Record<string, KeyValidator> = {
AlwaysTrue: () => Promise.resolve(true),
AlwaysFalse: () => Promise.resolve(false),
Bcrypt: isKeyValid,
};
function mockWorkerEnv(input: { HIVE_DATA: Map<string, string>; 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);
});