Skip to content
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

fix: only allow CDN access with the correct access key #147

Merged
merged 9 commits into from
Jun 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions integration-tests/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ services:
- './run-local-cdn.sh:/run-local-cdn.sh'
environment:
PORT: 3004
CDN_AUTH_PRIVATE_KEY: 1e1064ef9cda8bf38936b77317e90dc3

webhooks:
image: node:16.13.2-alpine3.14
Expand Down
166 changes: 166 additions & 0 deletions integration-tests/tests/api/schema/publish.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import {
fetchSchemaFromCDN,
createTarget,
fetchMetadataFromCDN,
createCdnAccess,
} from '../../../testkit/flow';
import { authenticate } from '../../../testkit/auth';
import axios from 'axios';

test('cannot publish a schema without target:registry:write access', async () => {
const { access_token: owner_access_token } = await authenticate('main');
Expand Down Expand Up @@ -919,3 +921,167 @@ test('marking only the most recent version as valid result in an update of CDN',
expect(cdnMetadataResult.status).toEqual(200);
expect(cdnMetadataResult.body).toEqual({ c2: 1 });
});

test('CDN data can not be fetched with an invalid access token', async () => {
const { access_token: owner_access_token } = await authenticate('main');
const orgResult = await createOrganization(
{
name: 'foo',
},
owner_access_token
);

// Join
const { access_token: member_access_token } = await authenticate('extra');
const org = orgResult.body.data!.createOrganization.ok!.createdOrganizationPayload.organization;
const code = org.inviteCode;
await joinOrganization(code, member_access_token);

const projectResult = await createProject(
{
organization: org.cleanId,
type: ProjectType.Single,
name: 'foo',
},
owner_access_token
);

const project = projectResult.body.data!.createProject.ok!.createdProject;
const target = projectResult.body.data!.createProject.ok!.createdTarget;

const tokenResult = await createToken(
{
name: 'test',
organization: org.cleanId,
project: project.cleanId,
target: target.cleanId,
organizationScopes: [],
projectScopes: [],
targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite],
},
owner_access_token
);

expect(tokenResult.body.errors).not.toBeDefined();

const token = tokenResult.body.data!.createToken.ok!.secret;

// Initial schema
const result = await publishSchema(
{
author: 'Kamil',
commit: 'c0',
sdl: `type Query { ping: String }`,
metadata: JSON.stringify({ c0: 1 }),
},
token
);

expect(result.body.errors).not.toBeDefined();
expect(result.body.data!.schemaPublish.__typename).toBe('SchemaPublishSuccess');

const targetSelector = {
organization: org.cleanId,
project: project.cleanId,
target: target.cleanId,
};

const cdnAccessResult = await createCdnAccess(targetSelector, token);

if (cdnAccessResult.body.errors) {
throw new Error(cdnAccessResult.body.errors[0].message);
}

const cdn = cdnAccessResult.body.data!.createCdnToken;

await expect(() =>
axios.get<{ sdl: string }>(`${cdn.url}/schema`, {
headers: {
'X-Hive-CDN-Key': 'i-like-turtles',
},
responseType: 'json',
})
).rejects.toThrow(/403/);
});

test('CDN data can be fetched with an valid access token', async () => {
const { access_token: owner_access_token } = await authenticate('main');
const orgResult = await createOrganization(
{
name: 'foo',
},
owner_access_token
);

// Join
const { access_token: member_access_token } = await authenticate('extra');
const org = orgResult.body.data!.createOrganization.ok!.createdOrganizationPayload.organization;
const code = org.inviteCode;
await joinOrganization(code, member_access_token);

const projectResult = await createProject(
{
organization: org.cleanId,
type: ProjectType.Single,
name: 'foo',
},
owner_access_token
);

const project = projectResult.body.data!.createProject.ok!.createdProject;
const target = projectResult.body.data!.createProject.ok!.createdTarget;

const tokenResult = await createToken(
{
name: 'test',
organization: org.cleanId,
project: project.cleanId,
target: target.cleanId,
organizationScopes: [],
projectScopes: [],
targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite],
},
owner_access_token
);

expect(tokenResult.body.errors).not.toBeDefined();

const token = tokenResult.body.data!.createToken.ok!.secret;

// Initial schema
const result = await publishSchema(
{
author: 'Kamil',
commit: 'c0',
sdl: `type Query { ping: String }`,
metadata: JSON.stringify({ c0: 1 }),
},
token
);

expect(result.body.errors).not.toBeDefined();
expect(result.body.data!.schemaPublish.__typename).toBe('SchemaPublishSuccess');

const targetSelector = {
organization: org.cleanId,
project: project.cleanId,
target: target.cleanId,
};

const cdnAccessResult = await createCdnAccess(targetSelector, token);

if (cdnAccessResult.body.errors) {
throw new Error(cdnAccessResult.body.errors[0].message);
}

const cdn = cdnAccessResult.body.data!.createCdnToken;

const cdnResult = await axios.get<{ sdl: string }>(`${cdn.url}/schema`, {
headers: {
'X-Hive-CDN-Key': cdn.token,
},
responseType: 'json',
});

expect(cdnResult.status).toEqual(200);
});
8 changes: 3 additions & 5 deletions packages/services/cdn-worker/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
const encoder = new TextEncoder();
const SECRET_KEY_DATA = encoder.encode(KEY_DATA);

export function byteStringToUint8Array(byteString: string) {
const ui = new Uint8Array(byteString.length);
Expand All @@ -13,11 +12,10 @@ export function byteStringToUint8Array(byteString: string) {

export async function isKeyValid(targetId: string, headerKey: string): Promise<boolean> {
const headerData = byteStringToUint8Array(atob(headerKey));
const secretKey = await crypto.subtle.importKey('raw', SECRET_KEY_DATA, { name: 'HMAC', hash: 'SHA-256' }, false, [
const secretKeyData = encoder.encode(KEY_DATA);
const secretKey = await crypto.subtle.importKey('raw', secretKeyData, { name: 'HMAC', hash: 'SHA-256' }, false, [
'verify',
]);

const verified = await crypto.subtle.verify('HMAC', secretKey, headerData, encoder.encode(targetId));

return verified;
return await crypto.subtle.verify('HMAC', secretKey, headerData, encoder.encode(targetId));
}
4 changes: 3 additions & 1 deletion packages/services/cdn-worker/src/dev-polyfill.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Response, Request, Headers, ReadableStream } from 'cross-undici-fetch';
import { webcrypto } from 'crypto';

globalThis.Response = Response;
globalThis.Request = Request;
Expand All @@ -7,5 +8,6 @@ globalThis.ReadableStream = ReadableStream;

export const devStorage = new Map<string, string>();

(globalThis as any).KEY_DATA = '';
(globalThis as any).KEY_DATA = process.env.CDN_AUTH_PRIVATE_KEY || '';
(globalThis as any).HIVE_DATA = devStorage;
(globalThis as any).crypto = webcrypto as any;
3 changes: 2 additions & 1 deletion packages/services/cdn-worker/src/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { handleRequest } from './handler';
import type { ServerResponse } from 'http';
import { Readable } from 'stream';
import { devStorage } from './dev-polyfill';
import { isKeyValid } from './auth';

const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 4010;

Expand Down Expand Up @@ -141,7 +142,7 @@ async function main() {
protocol: 'http',
endpoint: '/',
}),
async () => true
isKeyValid
);

sendNodeResponse(response, reply.raw);
Expand Down
36 changes: 24 additions & 12 deletions packages/services/cdn-worker/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,17 @@ const artifactTypesHandlers = {
const VALID_ARTIFACT_TYPES = Object.keys(artifactTypesHandlers);
const AUTH_HEADER_NAME = 'x-hive-cdn-key';

function parseIncomingRequest(
async function parseIncomingRequest(
request: Request,
keyValidator: typeof isKeyValid
):
): Promise<
| { error: Response }
| {
targetId: string;
artifactType: keyof typeof artifactTypesHandlers;
storageKeyType: string;
} {
}
> {
const params = new URL(request.url).pathname.replace(/^\/+/, '/').split('/').filter(Boolean);
const targetId = params[0];

Expand All @@ -97,22 +98,33 @@ function parseIncomingRequest(
return { error: new MissingAuthKey() };
}

if (!keyValidator(targetId, headerKey)) {
try {
const keyValid = await keyValidator(targetId, headerKey);

if (!keyValid) {
return {
error: new InvalidAuthKey(),
};
}

return {
targetId,
artifactType,
storageKeyType:
artifactType === 'sdl' || artifactType === 'introspection' || artifactType === 'schema'
? 'schema'
: artifactType,
};
} catch (e) {
console.warn(`Failed to validate key for ${targetId}, error:`, e);
return {
error: new InvalidAuthKey(),
};
}

return {
targetId,
artifactType,
storageKeyType:
artifactType === 'sdl' || artifactType === 'introspection' || artifactType === 'schema' ? 'schema' : artifactType,
};
}

export async function handleRequest(request: Request, keyValidator: typeof isKeyValid) {
const parsedRequest = parseIncomingRequest(request, keyValidator);
const parsedRequest = await parseIncomingRequest(request, keyValidator);

if ('error' in parsedRequest) {
return parsedRequest.error;
Expand Down