Skip to content

Commit

Permalink
feat: adding basic authentication to cid-verifier (#128)
Browse files Browse the repository at this point in the history
- [x] Set secret in wrangler for cid-verifier
- [x] Set secret in wrangler for edge-gateway

Closes #66
  • Loading branch information
jsdevel authored Dec 16, 2022
1 parent dbc6517 commit b8b9c94
Show file tree
Hide file tree
Showing 12 changed files with 151 additions and 21 deletions.
1 change: 1 addition & 0 deletions packages/cid-verifier/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`
Expand Down
48 changes: 48 additions & 0 deletions packages/cid-verifier/src/auth.js
Original file line number Diff line number Diff line change
@@ -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<Request>} handler
*/
export function withAuthToken (handler) {
/**
* @param {Request} request
* @param {import('./env').Env} env
* @returns {Promise<Response>}
*/
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)
}
1 change: 1 addition & 0 deletions packages/cid-verifier/src/bindings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Logging } from "@web3-storage/worker-utils/loki";
export {};

export interface EnvInput {
BASIC_AUTH_TOKENS: Array<string>;
ENV: string;
DEBUG: string;
GOOGLE_EVALUATE_SAFE_CONFIDENCE_LEVELS: Array<string>;
Expand Down
39 changes: 39 additions & 0 deletions packages/cid-verifier/src/errors.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 3 additions & 2 deletions packages/cid-verifier/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions packages/cid-verifier/test/auth.spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
10 changes: 9 additions & 1 deletion packages/cid-verifier/test/utils/miniflare.js
Original file line number Diff line number Diff line change
Expand Up @@ -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=='
]
}
})
}
31 changes: 17 additions & 14 deletions packages/cid-verifier/test/verification.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)
})
1 change: 1 addition & 0 deletions packages/edge-gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`
Expand Down
1 change: 1 addition & 0 deletions packages/edge-gateway/src/bindings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions packages/edge-gateway/src/gateway.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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}` } })
)
}

Expand Down
3 changes: 2 additions & 1 deletion packages/edge-gateway/test/utils/miniflare.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) {
Expand Down

0 comments on commit b8b9c94

Please sign in to comment.