From b4212083667d17be61176a9f79f83e49bc8d4e0a Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Thu, 13 Jun 2024 15:54:12 -0700 Subject: [PATCH 1/4] wip: spike on adding rate limits to freeway Add a new middleware that checks a rate limiting service and returns a 429 if the CID is over a rate limit. This sketches out an API for the rate limiting and accounting services suggested in https://github.com/storacha-network/RFC/pull/28 This is not ready to merge, but should probably be the starting point for this work once we all agree that this is the right shape. --- src/bindings.d.ts | 30 ++++++++++++++++++++++++++++ src/index.js | 4 +++- src/middleware.js | 51 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/bindings.d.ts b/src/bindings.d.ts index 79eea4f..87193da 100644 --- a/src/bindings.d.ts +++ b/src/bindings.d.ts @@ -3,6 +3,7 @@ import type { Context } from '@web3-storage/gateway-lib' import type { CARLink } from 'cardex/api' import type { R2Bucket, KVNamespace } from '@cloudflare/workers-types' import type { MemoryBudget } from './lib/mem-budget' +import { CID } from '@web3-storage/gateway-lib/handlers' export {} @@ -13,6 +14,8 @@ export interface Environment { SATNAV: R2Bucket MAX_SHARDS: string CONTENT_CLAIMS_SERVICE_URL?: string + RATE_LIMITS_SERVICE_URL?: string + ACCOUNTING_SERVICE_URL: string } /** @@ -45,3 +48,30 @@ export interface IndexSource { export interface IndexSourcesContext extends Context { indexSources: IndexSource[] } + +export type GetCIDRequestData = Pick + +export type GetCIDRequestOptions = GetCIDRequestData + +export enum RateLimitExceeded { + YES, + NO, + MAYBE +} + +export interface RateLimitsService { + check: (cid: CID, options: GetCIDRequestOptions) => Promise +} + +export interface RateLimits { + create: ({ serviceURL }: { serviceURL?: URL }) => RateLimitsService +} + +export interface AccountingService { + record: (cid: CID, options: GetCIDRequestOptions) => Promise +} + +export interface Accounting { + create: ({ serviceURL }: { serviceURL?: URL }) => AccountingService +} + diff --git a/src/index.js b/src/index.js index 4939f0b..8c3b420 100644 --- a/src/index.js +++ b/src/index.js @@ -19,7 +19,8 @@ import { withContentClaimsDagula, withHttpRangeUnsupported, withVersionHeader, - withCarBlockHandler + withCarBlockHandler, + withRateLimits } from './middleware.js' /** @@ -35,6 +36,7 @@ export default { fetch (request, env, ctx) { console.log(request.method, request.url) const middleware = composeMiddleware( + withRateLimits, withCdnCache, withContext, withCorsHeaders, diff --git a/src/middleware.js b/src/middleware.js index 89cde4b..16f19b4 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -12,6 +12,7 @@ import { MultiCarIndex, StreamingCarIndex } from './lib/dag-index/car.js' import { CachingBucket, asSimpleBucket } from './lib/bucket.js' import { MAX_CAR_BYTES_IN_MEMORY, CAR_CODE } from './constants.js' import { handleCarBlock } from './handlers/car-block.js' +import { RateLimitExceeded } from './bindings.js' /** * @typedef {import('./bindings.js').Environment} Environment @@ -22,6 +23,56 @@ import { handleCarBlock } from './handlers/car-block.js' * @typedef {import('@web3-storage/gateway-lib').UnixfsContext} UnixfsContext */ + +/** + * @type {import('./bindings.js').RateLimits} + */ +const RateLimits = { + create: ({ serviceURL }) => ({ + check: async (cid, options) => { + console.log(`checking ${serviceURL} to see if rate limits are exceeded for ${cid} with options`, options) + return RateLimitExceeded.MAYBE + } + }) +} + +/** + * @type {import('./bindings.js').Accounting} + */ +const Accounting = { + create: ({ serviceURL }) => ({ + record: async (cid, options) => { + console.log(`using ${serviceURL} to record a GET for ${cid} with options`, options) + } + }) +} + +/** + * + * @type {import('@web3-storage/gateway-lib').Middleware} + */ +export function withRateLimits(handler) { + return async (request, env, ctx) => { + const { dataCid } = ctx + const rateLimits = RateLimits.create({ + serviceURL: env.RATE_LIMITS_SERVICE_URL ? new URL(env.RATE_LIMITS_SERVICE_URL) : undefined + }) + const isRateLimitExceeded = await rateLimits.check(dataCid, request) + if (isRateLimitExceeded === RateLimitExceeded.YES) { + // TODO should we record this? + throw new HttpError('Too Many Requests', { status: 429 }) + } else { + const accounting = Accounting.create({ + serviceURL: env.ACCOUNTING_SERVICE_URL ? new URL(env.ACCOUNTING_SERVICE_URL) : undefined + }) + // ignore the response from the accounting service - this is "fire and forget" + void accounting.record(dataCid, request) + return handler(request, env, ctx) + } + } +} + + /** * Validates the request does not contain a HTTP `Range` header. * Returns 501 Not Implemented in case it has. From a4a8bb4e492130dfa6456d405f2ab8bb5cdc74ca Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Wed, 18 Sep 2024 15:57:03 -0700 Subject: [PATCH 2/4] feat: flesh out token flow introduce a KV namespace for caching token metadata - for now the "invalid" boolean is the most important thing, as that's how we decide whether a token is valid. I thought we'd be able to serve tokened requests when in doubt, but I'm a bit worried about people generating random tokens if we go that route - sending a different random token with each request would theoretically allow them to make infinite unbilled requests. instead I went with a pattern that will likely require us to warm the cache before a token-based request achieves hot-storage level performance - we cache auth token metadata in KV and wait for the accounting service to return it if we don't find it in the cache. Using a "Stale While Revalidate" pattern to update the cache will allow us to maintain performance and the ability to disable tokens. --- src/bindings.d.ts | 18 ++++---- src/constants.js | 6 +++ src/middleware.js | 115 +++++++++++++++++++++++++++++++++++++++++----- wrangler.toml | 18 ++++++++ 4 files changed, 137 insertions(+), 20 deletions(-) diff --git a/src/bindings.d.ts b/src/bindings.d.ts index 87193da..b1a944c 100644 --- a/src/bindings.d.ts +++ b/src/bindings.d.ts @@ -16,6 +16,8 @@ export interface Environment { CONTENT_CLAIMS_SERVICE_URL?: string RATE_LIMITS_SERVICE_URL?: string ACCOUNTING_SERVICE_URL: string + MY_RATE_LIMITER: RateLimit + AUTH_TOKEN_METADATA: KVNamespace } /** @@ -53,25 +55,25 @@ export type GetCIDRequestData = Pick export type GetCIDRequestOptions = GetCIDRequestData -export enum RateLimitExceeded { - YES, - NO, - MAYBE -} - export interface RateLimitsService { check: (cid: CID, options: GetCIDRequestOptions) => Promise } +export interface TokenMetadata { + locationClaim?: unknown // TODO: figure out the right type to use for this - we probably need it for the private data case to verify auth + invalid?: boolean +} + export interface RateLimits { - create: ({ serviceURL }: { serviceURL?: URL }) => RateLimitsService + create: ({ env }: { env: Environment }) => RateLimitsService } export interface AccountingService { record: (cid: CID, options: GetCIDRequestOptions) => Promise + getTokenMetadata: (token: string) => Promise } export interface Accounting { - create: ({ serviceURL }: { serviceURL?: URL }) => AccountingService + create: ({ serviceURL }: { serviceURL?: string }) => AccountingService } diff --git a/src/constants.js b/src/constants.js index 5a515fc..c2fbceb 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,2 +1,8 @@ export const MAX_CAR_BYTES_IN_MEMORY = 1024 * 1024 * 5 export const CAR_CODE = 0x0202 + +export const RATE_LIMIT_EXCEEDED = { + NO: 0, + YES: 1, + MAYBE: 2 +} \ No newline at end of file diff --git a/src/middleware.js b/src/middleware.js index 16f19b4..646f51d 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -10,9 +10,8 @@ import { BatchingR2Blockstore } from './lib/blockstore.js' import { version } from '../package.json' import { MultiCarIndex, StreamingCarIndex } from './lib/dag-index/car.js' import { CachingBucket, asSimpleBucket } from './lib/bucket.js' -import { MAX_CAR_BYTES_IN_MEMORY, CAR_CODE } from './constants.js' +import { MAX_CAR_BYTES_IN_MEMORY, CAR_CODE, RATE_LIMIT_EXCEEDED } from './constants.js' import { handleCarBlock } from './handlers/car-block.js' -import { RateLimitExceeded } from './bindings.js' /** * @typedef {import('./bindings.js').Environment} Environment @@ -23,15 +22,93 @@ import { RateLimitExceeded } from './bindings.js' * @typedef {import('@web3-storage/gateway-lib').UnixfsContext} UnixfsContext */ +/** + * + * @param {string} s + * @returns {import('./bindings.js').TokenMetadata} + */ +function deserializeTokenMetadata(s) { + // TODO should this be dag-json? + return JSON.parse(s) +} + +/** + * + * @param {import('./bindings.js').TokenMetadata} m + * @returns string + */ +function serializeTokenMetadata(m) { + // TODO should this be dag-json? + return JSON.stringify(m) +} + +/** + * + * @param {Environment} env + * @param {import('@web3-storage/gateway-lib/handlers').CID} cid + */ +async function checkRateLimitForCID(env, cid) { + const rateLimitResponse = await env.MY_RATE_LIMITER.limit({ key: cid.toString() }) + if (rateLimitResponse.success) { + return RATE_LIMIT_EXCEEDED.NO + } else { + console.log(`limiting CID ${cid}`) + return RATE_LIMIT_EXCEEDED.YES + } +} + +/** + * + * @param {Environment} env + * @param {string} authToken + * @returns TokenMetadata + */ +async function getTokenMetadata(env, authToken) { + const cachedValue = await env.AUTH_TOKEN_METADATA.get(authToken) + // TODO: we should implement an SWR pattern here - record an expiry in the metadata and if the expiry has passed, re-validate the cache after + // returning the value + if (cachedValue) { + return deserializeTokenMetadata(cachedValue) + } else { + const accounting = Accounting.create({ serviceURL: env.ACCOUNTING_SERVICE_URL }) + const tokenMetadata = await accounting.getTokenMetadata(authToken) + if (tokenMetadata) { + await env.AUTH_TOKEN_METADATA.put(authToken, serializeTokenMetadata(tokenMetadata)) + return tokenMetadata + } else { + return null + } + } +} /** * @type {import('./bindings.js').RateLimits} */ const RateLimits = { - create: ({ serviceURL }) => ({ + create: ({ env }) => ({ check: async (cid, options) => { - console.log(`checking ${serviceURL} to see if rate limits are exceeded for ${cid} with options`, options) - return RateLimitExceeded.MAYBE + const authToken = await getAuthorizationTokenFromRequest(options) + if (authToken) { + console.log(`found token ${authToken}, looking for content commitment`) + const tokenMetadata = await getTokenMetadata(env, authToken) + + if (tokenMetadata) { + if (tokenMetadata.invalid) { + return checkRateLimitForCID(env, cid) + } else { + // TODO at some point we should enforce user configurable rate limits and origin matching + // but for now we just serve all valid token requests + return RATE_LIMIT_EXCEEDED.NO + } + } else { + // we didn't get any metadata - for now just use the top level rate limit + // this means token based requests will be subject to normal rate limits until the data propagates + return checkRateLimitForCID(env, cid) + } + } else { + // no token, use normal rate limit + return checkRateLimitForCID(env, cid) + } } }) } @@ -43,10 +120,26 @@ const Accounting = { create: ({ serviceURL }) => ({ record: async (cid, options) => { console.log(`using ${serviceURL} to record a GET for ${cid} with options`, options) + }, + + getTokenMetadata: async () => { + // TODO I think this needs to check the content claims service (?) for any claims relevant to this token + // TODO do we have a plan for this? need to ask Hannah if the indexing service covers this? + return null } }) } +/** + * + * @param {Pick} request + * @returns string + */ +async function getAuthorizationTokenFromRequest(request) { + const authToken = request.headers.get('Authorization') + return authToken +} + /** * * @type {import('@web3-storage/gateway-lib').Middleware} @@ -54,17 +147,15 @@ const Accounting = { export function withRateLimits(handler) { return async (request, env, ctx) => { const { dataCid } = ctx - const rateLimits = RateLimits.create({ - serviceURL: env.RATE_LIMITS_SERVICE_URL ? new URL(env.RATE_LIMITS_SERVICE_URL) : undefined - }) + + const rateLimits = RateLimits.create({ env }) const isRateLimitExceeded = await rateLimits.check(dataCid, request) - if (isRateLimitExceeded === RateLimitExceeded.YES) { + + if (isRateLimitExceeded === RATE_LIMIT_EXCEEDED.YES) { // TODO should we record this? throw new HttpError('Too Many Requests', { status: 429 }) } else { - const accounting = Accounting.create({ - serviceURL: env.ACCOUNTING_SERVICE_URL ? new URL(env.ACCOUNTING_SERVICE_URL) : undefined - }) + const accounting = Accounting.create({ serviceURL: env.ACCOUNTING_SERVICE_URL }) // ignore the response from the accounting service - this is "fire and forget" void accounting.record(dataCid, request) return handler(request, env, ctx) diff --git a/wrangler.toml b/wrangler.toml index 0e1c0b4..26e5550 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -13,6 +13,24 @@ CONTENT_CLAIMS_SERVICE_URL = "https://dev.claims.web3.storage" [build] command = "npm run build:debug" +[[unsafe.bindings]] +# TODO BEFORE MERGE - update this to work in all environments - useful to do it like this for now +name = "MY_RATE_LIMITER" +type = "ratelimit" +# An identifier you define, that is unique to your Cloudflare account. +# Must be an integer. +namespace_id = "0" + +# Limit: the number of tokens allowed within a given period in a single +# Cloudflare location +# Period: the duration of the period, in seconds. Must be either 10 or 60 +simple = { limit = 100, period = 60 } + +[[kv_namespaces]] +# TODO BEFORE MERGE - update this to work in all environments - useful to do it like this for now +binding = "AUTH_TOKEN_METADATA" +id = "f848730e45d94f17bcaf3b6d0915da40" + # PROD! [env.production] account_id = "fffa4b4363a7e5250af8357087263b3a" From 10d8fe89d43b69fd9d7562606a9d6be3ae152c35 Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Thu, 19 Sep 2024 14:34:45 -0700 Subject: [PATCH 3/4] more comments --- src/middleware.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/middleware.js b/src/middleware.js index 646f51d..972b9fd 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -94,6 +94,7 @@ const RateLimits = { if (tokenMetadata) { if (tokenMetadata.invalid) { + // this means we know about the token and we know it's invalid, so we should just use the CID rate limit return checkRateLimitForCID(env, cid) } else { // TODO at some point we should enforce user configurable rate limits and origin matching @@ -136,6 +137,7 @@ const Accounting = { * @returns string */ async function getAuthorizationTokenFromRequest(request) { + // TODO this is probably wrong const authToken = request.headers.get('Authorization') return authToken } From 7e6102663096b27593a88b736da81521e49ceb4d Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Thu, 19 Sep 2024 14:46:26 -0700 Subject: [PATCH 4/4] fix: clean up merge --- src/middleware.js | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/middleware.js b/src/middleware.js index 09d6a72..78ccf4b 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -158,23 +158,6 @@ export function withRateLimits(handler) { } } - -/** - * Validates the request does not contain a HTTP `Range` header. - * Returns 501 Not Implemented in case it has. - * @type {import('@web3-storage/gateway-lib').Middleware} - */ -export function withHttpRangeUnsupported (handler) { - return (request, env, ctx) => { - // Range request https://github.com/web3-storage/gateway-lib/issues/12 - if (request.headers.get('range')) { - throw new HttpError('Not Implemented', { status: 501 }) - } - - return handler(request, env, ctx) - } -} - /** * Middleware that will serve CAR files if a CAR codec is found in the path * CID. If the CID is not a CAR CID it delegates to the next middleware.