diff --git a/packages/cid-verifier/README.md b/packages/cid-verifier/README.md index 6fb8ea8..98ddf55 100644 --- a/packages/cid-verifier/README.md +++ b/packages/cid-verifier/README.md @@ -17,6 +17,7 @@ wrangler secret put LOKI_TOKEN --env $(whoami) # Get from Loki wrangler secret put GOOGLE_CLOUD_API_URL --env $(whoami) # Get from 1password wrangler secret put GOOGLE_CLOUD_API_KEY --env $(whoami) # Get from 1password + wrangler secret put BASIC_AUTH_TOKENS --env $(whoami) # JSON String with array of allowable auth tokens (eg. echo -e '["Zm9vOmZvbw==","em9vOnpvbw=="]' | wrangler secret ...) ``` - `pnpm run publish` - Publish the worker under desired env. An alias for `wrangler publish --env $(whoami)` diff --git a/packages/cid-verifier/src/auth.js b/packages/cid-verifier/src/auth.js new file mode 100644 index 0000000..03f76ff --- /dev/null +++ b/packages/cid-verifier/src/auth.js @@ -0,0 +1,48 @@ +import { NoTokenError, ExpectedBasicStringError, NoValidTokenError } from './errors.js' + +/** + * Middleware: verify the request is authenticated using Basic Authentication. + * + * @param {import('itty-router').RouteHandler} handler + */ +export function withAuthToken (handler) { + /** + * @param {Request} request + * @param {import('./env').Env} env + * @returns {Promise} + */ + return async (request, env) => { + const token = getTokenFromRequest(request) + if (env.BASIC_AUTH_TOKENS.indexOf(token) === -1) { + throw new NoValidTokenError() + } + return await handler(request, env) + } +} + +/** + * @param {Request} request + */ +function getTokenFromRequest (request) { + const authHeader = request.headers.get('Authorization') || '' + if (!authHeader) { + throw new NoTokenError() + } + + const token = parseAuthorizationHeader(authHeader) + if (!token) { + throw new NoTokenError() + } + return token +} + +/** + * @param {string} header + */ +function parseAuthorizationHeader (header) { + if (!header.toLowerCase().startsWith('basic ')) { + throw new ExpectedBasicStringError() + } + + return header.substring(6) +} diff --git a/packages/cid-verifier/src/bindings.d.ts b/packages/cid-verifier/src/bindings.d.ts index 83145a8..0785e54 100644 --- a/packages/cid-verifier/src/bindings.d.ts +++ b/packages/cid-verifier/src/bindings.d.ts @@ -4,6 +4,7 @@ import { Logging } from "@web3-storage/worker-utils/loki"; export {}; export interface EnvInput { + BASIC_AUTH_TOKENS: Array; ENV: string; DEBUG: string; GOOGLE_EVALUATE_SAFE_CONFIDENCE_LEVELS: Array; diff --git a/packages/cid-verifier/src/errors.js b/packages/cid-verifier/src/errors.js index 06d0bcf..dce3ba1 100644 --- a/packages/cid-verifier/src/errors.js +++ b/packages/cid-verifier/src/errors.js @@ -1,3 +1,42 @@ +export class NoTokenError extends Error { + /** + * @param {string} message + */ + constructor (message = 'No token found in `Authorization: Basic ` header') { + super(message) + this.name = 'NoToken' + this.code = NoTokenError.CODE + this.status = 401 + } +} +NoTokenError.CODE = 'ERROR_NO_TOKEN' + +export class ExpectedBasicStringError extends Error { + /** + * @param {string} message + */ + constructor (message = 'Expected argument to be a string in the `Basic {token}` format') { + super(message) + this.name = 'ExpectedBasicString' + this.code = ExpectedBasicStringError.CODE + this.status = 401 + } +} +ExpectedBasicStringError.CODE = 'ERROR_NO_TOKEN' + +export class NoValidTokenError extends Error { + /** + * @param {string} message + */ + constructor (message = 'Provided token is not valid') { + super(message) + this.name = 'NoValidToken' + this.code = NoValidTokenError.CODE + this.status = 401 + } +} +NoValidTokenError.CODE = 'ERROR_NO_VALID_TOKEN' + export class ServiceUnavailableError extends Error { /** * @param {string} message diff --git a/packages/cid-verifier/src/index.js b/packages/cid-verifier/src/index.js index ab587c9..1e2a391 100644 --- a/packages/cid-verifier/src/index.js +++ b/packages/cid-verifier/src/index.js @@ -5,6 +5,7 @@ import { Router } from 'itty-router' import { verificationGet, verificationPost } from './verification.js' import { versionGet } from './version.js' +import { withAuthToken } from './auth.js' import { addCorsHeaders, withCorsHeaders } from './cors.js' import { errorHandler } from './error-handler.js' import { envAll } from './env.js' @@ -17,8 +18,8 @@ const router = Router() router .all('*', envAll) .get('/version', withCorsHeaders(versionGet)) - .get('/denylist', withCorsHeaders(verificationGet)) - .post('/', withCorsHeaders(verificationPost)) + .get('/denylist', withCorsHeaders(withAuthToken(verificationGet))) + .post('/', withCorsHeaders(withAuthToken(verificationPost))) /** * @param {Error} error diff --git a/packages/cid-verifier/test/auth.spec.js b/packages/cid-verifier/test/auth.spec.js new file mode 100644 index 0000000..34e4094 --- /dev/null +++ b/packages/cid-verifier/test/auth.spec.js @@ -0,0 +1,26 @@ +import { test, getMiniflare } from './utils/setup.js' + +test.before(async (t) => { + const mf = getMiniflare() + t.context = { + mf + } +}) + +test('GET /denylist fails with no Authorization header', async (t) => { + const { mf } = t.context + const response = await mf.dispatchFetch('http://localhost:8787/denylist?cid=never') + t.is(response.status, 401) +}) + +test('GET /denylist fails with invalid Authorization header', async (t) => { + const { mf } = t.context + const response = await mf.dispatchFetch('http://localhost:8787/denylist?cid=never', { headers: { Authorization: '' } }) + t.is(response.status, 401) +}) + +test('GET /denylist fails with unknown Authorization credentials', async (t) => { + const { mf } = t.context + const response = await mf.dispatchFetch('http://localhost:8787/denylist?cid=never', { headers: { Authorization: 'basic asdfasdf' } }) + t.is(response.status, 401) +}) diff --git a/packages/cid-verifier/test/utils/miniflare.js b/packages/cid-verifier/test/utils/miniflare.js index 15ff5fb..76842b9 100644 --- a/packages/cid-verifier/test/utils/miniflare.js +++ b/packages/cid-verifier/test/utils/miniflare.js @@ -20,6 +20,14 @@ export function getMiniflare () { // This will override the option in wrangler.toml. buildCommand: undefined, wranglerConfigEnv: 'test', - modules: true + modules: true, + bindings: { + BASIC_AUTH_TOKENS: [ + // echo -n 'foo:foo' | base64 + 'Zm9vOmZvbw==', + // echo -n 'zoo:zoo' | base64 + 'em9vOnpvbw==' + ] + } }) } diff --git a/packages/cid-verifier/test/verification.spec.js b/packages/cid-verifier/test/verification.spec.js index b6d07ea..050393d 100644 --- a/packages/cid-verifier/test/verification.spec.js +++ b/packages/cid-verifier/test/verification.spec.js @@ -18,6 +18,9 @@ const malwareCid = await createTestCid('malware') const maliciousCid = await createTestCid('malicious') const safeCid = await createTestCid('safe') const errorCid = await createTestCid('error') +const headers = { + Authorization: 'basic Zm9vOmZvbw==' +} // Create a new Miniflare environment for each test test.before(async (t) => { @@ -50,98 +53,98 @@ test.before(async (t) => { test('GET /denylist handles cids in DENYLIST', async (t) => { const { mf } = t.context - const response = await mf.dispatchFetch(`http://localhost:8787/denylist?cid=${cidInDenyList}`) + const response = await mf.dispatchFetch(`http://localhost:8787/denylist?cid=${cidInDenyList}`, { headers }) t.is(await response.text(), 'MALWARE DETECTED') t.is(response.status, 403) }) test('GET /denylist handles cids in DENYLIST blocked for legal reasons', async (t) => { const { mf } = t.context - const response = await mf.dispatchFetch(`http://localhost:8787/denylist?cid=${cidInDenyListBlockedForLeganReasons}`) + const response = await mf.dispatchFetch(`http://localhost:8787/denylist?cid=${cidInDenyListBlockedForLeganReasons}`, { headers }) t.is(await response.text(), 'BLOCKED FOR LEGAL REASONS') t.is(response.status, 451) }) test('GET /denylist handles no cid', async (t) => { const { mf } = t.context - const response = await mf.dispatchFetch('http://localhost:8787/denylist?') + const response = await mf.dispatchFetch('http://localhost:8787/denylist?', { headers }) t.is(await response.text(), 'cid is a required query param') t.is(response.status, 400) }) test('GET /denylist handles invalid cid', async (t) => { const { mf } = t.context - const response = await mf.dispatchFetch('http://localhost:8787/denylist?cid=invalid') + const response = await mf.dispatchFetch('http://localhost:8787/denylist?cid=invalid', { headers }) t.is(await response.text(), 'cid query param is invalid') t.is(response.status, 400) }) test('GET /denylist handles no results', async (t) => { const { mf } = t.context - const response = await mf.dispatchFetch(`http://localhost:8787/denylist?cid=${emptyCid}`) + const response = await mf.dispatchFetch(`http://localhost:8787/denylist?cid=${emptyCid}`, { headers }) t.is(await response.text(), '') t.is(response.status, 204) }) test('GET /denylist handles pending results', async (t) => { const { mf } = t.context - const response = await mf.dispatchFetch(`http://localhost:8787/denylist?cid=${pendingCid}`) + const response = await mf.dispatchFetch(`http://localhost:8787/denylist?cid=${pendingCid}`, { headers }) t.is(await response.text(), '') t.is(response.status, 204) }) test('GET /denylist handles successful results', async (t) => { const { mf } = t.context - const response = await mf.dispatchFetch(`http://localhost:8787/denylist?cid=${notMalwareCid}`) + const response = await mf.dispatchFetch(`http://localhost:8787/denylist?cid=${notMalwareCid}`, { headers }) t.is(await response.text(), '') t.is(response.status, 204) }) test('POST / handles no cid', async (t) => { const { mf } = t.context - const response = await mf.dispatchFetch('http://localhost:8787/?', { method: 'POST' }) + const response = await mf.dispatchFetch('http://localhost:8787/?', { method: 'POST', headers }) t.is(await response.text(), 'cid is a required query param') t.is(response.status, 400) }) test('POST / handles invalid cid', async (t) => { const { mf } = t.context - const response = await mf.dispatchFetch('http://localhost:8787/?cid=invalid', { method: 'POST' }) + const response = await mf.dispatchFetch('http://localhost:8787/?cid=invalid', { method: 'POST', headers }) t.is(await response.text(), 'cid query param is invalid') t.is(response.status, 400) }) test('POST / handles malicious urls', async (t) => { const { mf } = t.context - const response = await mf.dispatchFetch(`http://localhost:8787/?cid=${maliciousCid}`, { method: 'POST' }) + const response = await mf.dispatchFetch(`http://localhost:8787/?cid=${maliciousCid}`, { method: 'POST', headers }) t.is(await response.text(), 'cid malware detection processed') t.is(response.status, 201) }) test('POST / handles safe urls', async (t) => { const { mf } = t.context - const response = await mf.dispatchFetch(`http://localhost:8787/?cid=${safeCid}`, { method: 'POST' }) + const response = await mf.dispatchFetch(`http://localhost:8787/?cid=${safeCid}`, { method: 'POST', headers }) t.is(await response.text(), 'cid malware detection processed') t.is(response.status, 201) }) test('POST / handles invalid or error urls', async (t) => { const { mf } = t.context - const response = await mf.dispatchFetch(`http://localhost:8787/?cid=${errorCid}`, { method: 'POST' }) + const response = await mf.dispatchFetch(`http://localhost:8787/?cid=${errorCid}`, { method: 'POST', headers }) t.is(await response.text(), `GOOGLE CLOUD UNABLE TO VERIFY URL "https://${errorCid}.ipfs.link.test" status code "400"`) t.is(response.status, 503) }) test('POST / handles pending results', async (t) => { const { mf } = t.context - const response = await mf.dispatchFetch(`http://localhost:8787/?cid=${pendingCid}`, { method: 'POST' }) + const response = await mf.dispatchFetch(`http://localhost:8787/?cid=${pendingCid}`, { method: 'POST', headers }) t.is(await response.text(), 'cid malware detection already processed') t.is(response.status, 202) }) test('POST / handles overriding existing malware cid', async (t) => { const { mf } = t.context - const response = await mf.dispatchFetch(`http://localhost:8787/?cid=${malwareCid}`, { method: 'POST' }) + const response = await mf.dispatchFetch(`http://localhost:8787/?cid=${malwareCid}`, { method: 'POST', headers }) t.is(await response.text(), 'cid malware detection already processed') t.is(response.status, 202) }) diff --git a/packages/edge-gateway/README.md b/packages/edge-gateway/README.md index 859d8c2..d5951f9 100644 --- a/packages/edge-gateway/README.md +++ b/packages/edge-gateway/README.md @@ -18,6 +18,7 @@ wrangler secret put CDN_GATEWAYS_RACE --env $(whoami) # JSON String with array of CDN Gateways URLs (eg. echo -e '["https://freeway.dag.haus"]' | wrangler secret ...) wrangler secret put IPFS_GATEWAYS_RACE_L1 --env $(whoami) # JSON String with array of IPFS Gateways URLs (eg. echo -e '["https://ipfs.io","https://dagula.dag.haus"]' | wrangler secret ...) wrangler secret put IPFS_GATEWAYS_RACE_L2 --env $(whoami) # JSON String with array of IPFS Gateways URLs (eg. echo -e '["https://cf.dag.haus","https://w3link.mypinata.cloud"]' | wrangler secret ...) + wrangler secret put CID_VERIFIER_AUTHORIZATION_TOKEN --env $(whoami) # Get from 1Password ``` - `pnpm run publish` - Publish the worker under desired env. An alias for `wrangler publish --env $(whoami)` diff --git a/packages/edge-gateway/src/bindings.d.ts b/packages/edge-gateway/src/bindings.d.ts index 8c2c60a..0970ca4 100644 --- a/packages/edge-gateway/src/bindings.d.ts +++ b/packages/edge-gateway/src/bindings.d.ts @@ -17,6 +17,7 @@ export interface AnalyticsEngineEvent { export interface EnvInput { ENV: string DEBUG: string + CID_VERIFIER_AUTHORIZATION_TOKEN: string CID_VERIFIER_ENABLED: string CID_VERIFIER_URL: string CID_VERIFIER: Fetcher diff --git a/packages/edge-gateway/src/gateway.js b/packages/edge-gateway/src/gateway.js index e6f6a6e..adabfd8 100644 --- a/packages/edge-gateway/src/gateway.js +++ b/packages/edge-gateway/src/gateway.js @@ -78,7 +78,7 @@ export async function gatewayGet (request, env, ctx) { } } - const cidDenylistResponse = await env.CID_VERIFIER.fetch(`${env.CID_VERIFIER_URL}/denylist?cid=${cid}`) + const cidDenylistResponse = await env.CID_VERIFIER.fetch(`${env.CID_VERIFIER_URL}/denylist?cid=${cid}`, { headers: { Authorization: `basic ${env.CID_VERIFIER_AUTHORIZATION_TOKEN}` } }) if (cidDenylistResponse.status !== 204) { return cidDenylistResponse } @@ -121,7 +121,7 @@ export async function gatewayGet (request, env, ctx) { // Validation layer - resource CID const resourceCid = pathname !== '/' ? getCidFromEtag(winnerGwResponse.headers.get('etag') || cid) : cid if (winnerGwResponse && pathname !== '/' && resourceCid) { - const cidResourceDenylistResponse = await env.CID_VERIFIER.fetch(`${env.CID_VERIFIER_URL}/denylist?cid=${resourceCid}`) + const cidResourceDenylistResponse = await env.CID_VERIFIER.fetch(`${env.CID_VERIFIER_URL}/denylist?cid=${resourceCid}`, { headers: { Authorization: `basic ${env.CID_VERIFIER_AUTHORIZATION_TOKEN}` } }) // Ignore if CID received from gateway in etag header is invalid by any reason if (cidResourceDenylistResponse.status !== 204 && cidResourceDenylistResponse.status !== 400) { return cidResourceDenylistResponse @@ -135,7 +135,7 @@ export async function gatewayGet (request, env, ctx) { ) { // fire and forget. Let cid-verifier process this cid and url if it needs to ctx.waitUntil( - env.CID_VERIFIER.fetch(`${env.CID_VERIFIER_URL}/?cid=${resourceCid}`, { method: 'POST' }) + env.CID_VERIFIER.fetch(`${env.CID_VERIFIER_URL}/?cid=${resourceCid}`, { method: 'POST', headers: { Authorization: `basic ${env.CID_VERIFIER_AUTHORIZATION_TOKEN}` } }) ) } diff --git a/packages/edge-gateway/test/utils/miniflare.js b/packages/edge-gateway/test/utils/miniflare.js index 965c839..0850fcb 100644 --- a/packages/edge-gateway/test/utils/miniflare.js +++ b/packages/edge-gateway/test/utils/miniflare.js @@ -4,7 +4,8 @@ import { Miniflare } from 'miniflare' export const secrets = { IPFS_GATEWAYS_RACE_L1: '["http://127.0.0.1:9081"]', - IPFS_GATEWAYS_RACE_L2: '["http://localhost:9082", "http://localhost:9083"]' + IPFS_GATEWAYS_RACE_L2: '["http://localhost:9082", "http://localhost:9083"]', + CID_VERIFIER_AUTHORIZATION_TOKEN: 'em9vOnpvbw==' } export function getMiniflare (bindings = {}) {