diff --git a/.changeset/tidy-bobcats-lose.md b/.changeset/tidy-bobcats-lose.md new file mode 100644 index 000000000000..06ba5c4e919e --- /dev/null +++ b/.changeset/tidy-bobcats-lose.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/workers-shared": minor +--- + +Limit free tier requests in the Router worker diff --git a/packages/workers-shared/router-worker/src/analytics.ts b/packages/workers-shared/router-worker/src/analytics.ts index b86e325471e8..a4ebf8b6d0fa 100644 --- a/packages/workers-shared/router-worker/src/analytics.ts +++ b/packages/workers-shared/router-worker/src/analytics.ts @@ -33,6 +33,8 @@ type Data = { userWorkerAhead?: boolean; // double6 - Routing performed based on the _routes.json (if provided) staticRoutingDecision?: STATIC_ROUTING_DECISION; + // double7 - User worker invocation denied due to free tier limiting + userWorkerFreeTierLimiting?: boolean; // -- Blobs -- // blob1 - Hostname of the request @@ -88,6 +90,7 @@ export class Analytics { ? -1 : Number(this.data.userWorkerAhead), this.data.staticRoutingDecision ?? STATIC_ROUTING_DECISION.NOT_PROVIDED, // double6 + this.data.userWorkerFreeTierLimiting ? 1 : 0, // double7 ], blobs: [ this.data.hostname?.substring(0, 256), // blob1 - trim to 256 bytes diff --git a/packages/workers-shared/router-worker/src/configuration.ts b/packages/workers-shared/router-worker/src/configuration.ts index 3dde3943505d..2c44712dac58 100644 --- a/packages/workers-shared/router-worker/src/configuration.ts +++ b/packages/workers-shared/router-worker/src/configuration.ts @@ -1,6 +1,8 @@ -import type { RouterConfig } from "../../utils/types"; +import type { EyeballRouterConfig, RouterConfig } from "../../utils/types"; -export const applyConfigurationDefaults = ( +type RequiredEyeballRouterConfig = Required>; + +export const applyRouterConfigDefaults = ( configuration?: RouterConfig ): Required => { return { @@ -15,3 +17,11 @@ export const applyConfigurationDefaults = ( }, }; }; + +export const applyEyeballConfigDefaults = ( + eyeballConfiguration?: EyeballRouterConfig +): RequiredEyeballRouterConfig => { + return { + limitedAssetsOnly: eyeballConfiguration?.limitedAssetsOnly ?? false, + }; +}; diff --git a/packages/workers-shared/router-worker/src/worker.ts b/packages/workers-shared/router-worker/src/worker.ts index be02db0515fe..7a13778e14af 100644 --- a/packages/workers-shared/router-worker/src/worker.ts +++ b/packages/workers-shared/router-worker/src/worker.ts @@ -3,19 +3,46 @@ import { PerformanceTimer } from "../../utils/performance"; import { setupSentry } from "../../utils/sentry"; import { mockJaegerBinding } from "../../utils/tracing"; import { Analytics, DISPATCH_TYPE, STATIC_ROUTING_DECISION } from "./analytics"; -import { applyConfigurationDefaults } from "./configuration"; +import { + applyEyeballConfigDefaults, + applyRouterConfigDefaults, +} from "./configuration"; import type AssetWorker from "../../asset-worker"; import type { + EyeballRouterConfig, JaegerTracing, RouterConfig, UnsafePerformanceTimer, } from "../../utils/types"; import type { ColoMetadata, Environment, ReadyAnalytics } from "./types"; +const limitedResponse = ` + + This website is temporarily limited + + +

This website has been temporarily rate limited

+

+ You cannot access this site because the owner has reached their plan + limits. Check back later once traffic has gone down. +

+

+ If you are owner of this website, prevent this from happening again by + upgrading your plan on the + Cloudflare Workers dashboard. +

+ +`; + export interface Env { ASSET_WORKER: Service; USER_WORKER: Fetcher; CONFIG: RouterConfig; + EYEBALL_CONFIG: EyeballRouterConfig; SENTRY_DSN: string; ENVIRONMENT: Environment; @@ -55,7 +82,8 @@ export default { ); const hasStaticRouting = env.CONFIG.static_routing !== undefined; - const config = applyConfigurationDefaults(env.CONFIG); + const config = applyRouterConfigDefaults(env.CONFIG); + const eyeballConfig = applyEyeballConfigDefaults(env.EYEBALL_CONFIG); const url = new URL(request.url); @@ -77,6 +105,54 @@ export default { const maybeSecondRequest = request.clone(); + const routeToUserWorker = async ({ + asset, + }: { + asset: "static_routing" | "none"; + }) => { + if (!config.has_user_worker) { + throw new Error( + "Fetch for user worker without having a user worker binding" + ); + } + if (eyeballConfig.limitedAssetsOnly) { + analytics.setData({ userWorkerFreeTierLimiting: true }); + return new Response(limitedResponse, { + status: 429, + headers: { + "Content-Type": "text/html", + }, + }); + } + analytics.setData({ dispatchtype: DISPATCH_TYPE.WORKER }); + userWorkerInvocation = true; + return env.JAEGER.enterSpan("dispatch_worker", async (span) => { + span.setTags({ + hasUserWorker: true, + asset: asset, + dispatchType: DISPATCH_TYPE.WORKER, + }); + return env.USER_WORKER.fetch(maybeSecondRequest); + }); + }; + + const routeToAssets = async ({ + asset, + }: { + asset: "static_routing" | "found" | "none"; + }) => { + analytics.setData({ dispatchtype: DISPATCH_TYPE.ASSETS }); + return await env.JAEGER.enterSpan("dispatch_assets", async (span) => { + span.setTags({ + hasUserWorker: config.has_user_worker, + asset: asset, + dispatchType: DISPATCH_TYPE.ASSETS, + }); + + return env.ASSET_WORKER.fetch(maybeSecondRequest); + }); + }; + if (config.static_routing) { // evaluate "exclude" rules const excludeRulesMatcher = generateStaticRoutingRuleMatcher( @@ -89,18 +165,9 @@ export default { ) { // direct to asset worker analytics.setData({ - dispatchtype: DISPATCH_TYPE.ASSETS, staticRoutingDecision: STATIC_ROUTING_DECISION.ROUTED, }); - return await env.JAEGER.enterSpan("dispatch_assets", async (span) => { - span.setTags({ - hasUserWorker: config.has_user_worker, - asset: "static_routing", - dispatchType: DISPATCH_TYPE.ASSETS, - }); - - return env.ASSET_WORKER.fetch(maybeSecondRequest); - }); + return await routeToAssets({ asset: "static_routing" }); } // evaluate "include" rules const includeRulesMatcher = generateStaticRoutingRuleMatcher( @@ -118,19 +185,9 @@ export default { } // direct to user worker analytics.setData({ - dispatchtype: DISPATCH_TYPE.WORKER, staticRoutingDecision: STATIC_ROUTING_DECISION.ROUTED, }); - return await env.JAEGER.enterSpan("dispatch_worker", async (span) => { - span.setTags({ - hasUserWorker: true, - asset: "static_routing", - dispatchType: DISPATCH_TYPE.WORKER, - }); - - userWorkerInvocation = true; - return env.USER_WORKER.fetch(maybeSecondRequest); - }); + return await routeToUserWorker({ asset: "static_routing" }); } analytics.setData({ @@ -143,53 +200,17 @@ export default { // User's configuration indicates they want user-Worker to run ahead of any // assets. Do not provide any fallback logic. if (config.invoke_user_worker_ahead_of_assets) { - if (!config.has_user_worker) { - throw new Error( - "Fetch for user worker without having a user worker binding" - ); - } - - analytics.setData({ dispatchtype: DISPATCH_TYPE.WORKER }); - return await env.JAEGER.enterSpan("dispatch_worker", async (span) => { - span.setTags({ - hasUserWorker: true, - asset: "ignored", - dispatchType: DISPATCH_TYPE.WORKER, - }); - - userWorkerInvocation = true; - return env.USER_WORKER.fetch(maybeSecondRequest); - }); + return await routeToUserWorker({ asset: "static_routing" }); } // If we have a user-Worker, but no assets, dispatch to Worker script const assetsExist = await env.ASSET_WORKER.unstable_canFetch(request); if (config.has_user_worker && !assetsExist) { - analytics.setData({ dispatchtype: DISPATCH_TYPE.WORKER }); - - return await env.JAEGER.enterSpan("dispatch_worker", async (span) => { - span.setTags({ - hasUserWorker: config.has_user_worker, - asset: assetsExist, - dispatchType: DISPATCH_TYPE.WORKER, - }); - - userWorkerInvocation = true; - return env.USER_WORKER.fetch(maybeSecondRequest); - }); + return await routeToUserWorker({ asset: "none" }); } // Otherwise, we either don't have a user worker, OR we have matching assets and should fetch from the assets binding - analytics.setData({ dispatchtype: DISPATCH_TYPE.ASSETS }); - return await env.JAEGER.enterSpan("dispatch_assets", async (span) => { - span.setTags({ - hasUserWorker: config.has_user_worker, - asset: assetsExist, - dispatchType: DISPATCH_TYPE.ASSETS, - }); - - return env.ASSET_WORKER.fetch(maybeSecondRequest); - }); + return await routeToAssets({ asset: assetsExist ? "found" : "none" }); } catch (err) { if (userWorkerInvocation) { // Don't send user Worker errors to sentry; we have no way to distinguish between diff --git a/packages/workers-shared/router-worker/tests/index.test.ts b/packages/workers-shared/router-worker/tests/index.test.ts index 9916c9e6126d..35b842fbe04b 100644 --- a/packages/workers-shared/router-worker/tests/index.test.ts +++ b/packages/workers-shared/router-worker/tests/index.test.ts @@ -246,4 +246,163 @@ describe("unit tests", async () => { const response = await worker.fetch(request, env, ctx); expect(await response.text()).toEqual("hello from user worker"); }); + + describe("free tier limiting", () => { + it("returns fetch from asset worker for assets", async () => { + const request = new Request("https://example.com/asset"); + const ctx = createExecutionContext(); + + const env = { + CONFIG: { + has_user_worker: true, + }, + EYEBALL_CONFIG: { limitedAssetsOnly: true }, + USER_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from user worker"); + }, + }, + ASSET_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from asset worker"); + }, + async unstable_canFetch(_: Request): Promise { + return true; + }, + }, + } as Env; + + const response = await worker.fetch(request, env, ctx); + expect(await response.text()).toEqual("hello from asset worker"); + }); + + it("returns error page instead of user worker when no asset found", async () => { + const request = new Request("https://example.com/asset"); + const ctx = createExecutionContext(); + + const env = { + CONFIG: { + has_user_worker: true, + }, + EYEBALL_CONFIG: { limitedAssetsOnly: true }, + USER_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from user worker"); + }, + }, + ASSET_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from asset worker"); + }, + async unstable_canFetch(_: Request): Promise { + return false; + }, + }, + } as Env; + + const response = await worker.fetch(request, env, ctx); + expect(response.status).toEqual(429); + const text = await response.text(); + expect(text).not.toEqual("hello from user worker"); + expect(text).toContain("This website has been temporarily rate limited"); + }); + + it("returns error page instead of user worker for invoke_user_worker_ahead_of_assets", async () => { + const request = new Request("https://example.com/asset"); + const ctx = createExecutionContext(); + + const env = { + CONFIG: { + has_user_worker: true, + invoke_user_worker_ahead_of_assets: true, + }, + EYEBALL_CONFIG: { limitedAssetsOnly: true }, + USER_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from user worker"); + }, + }, + ASSET_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from asset worker"); + }, + async unstable_canFetch(_: Request): Promise { + return true; + }, + }, + } as Env; + + const response = await worker.fetch(request, env, ctx); + expect(response.status).toEqual(429); + const text = await response.text(); + expect(text).not.toEqual("hello from user worker"); + expect(text).toContain("This website has been temporarily rate limited"); + }); + + it("returns error page instead of user worker for user_worker rules", async () => { + const request = new Request("https://example.com/api/asset"); + const ctx = createExecutionContext(); + + const env = { + CONFIG: { + has_user_worker: true, + static_routing: { + user_worker: ["/api/*"], + }, + }, + EYEBALL_CONFIG: { limitedAssetsOnly: true }, + USER_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from user worker"); + }, + }, + ASSET_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from asset worker"); + }, + async unstable_canFetch(_: Request): Promise { + return true; + }, + }, + } as Env; + + const response = await worker.fetch(request, env, ctx); + expect(response.status).toEqual(429); + const text = await response.text(); + expect(text).not.toEqual("hello from user worker"); + expect(text).toContain("This website has been temporarily rate limited"); + }); + + it("returns fetch from asset worker for asset_worker rules", async () => { + const request = new Request("https://example.com/api/asset"); + const ctx = createExecutionContext(); + + const env = { + CONFIG: { + has_user_worker: true, + static_routing: { + user_worker: ["/api/*"], + asset_worker: ["/api/asset"], + }, + }, + EYEBALL_CONFIG: { limitedAssetsOnly: true }, + USER_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from user worker"); + }, + }, + ASSET_WORKER: { + async fetch(_: Request): Promise { + return new Response("hello from asset worker"); + }, + async unstable_canFetch(_: Request): Promise { + return true; + }, + }, + } as Env; + + const response = await worker.fetch(request, env, ctx); + expect(await response.text()).toEqual("hello from asset worker"); + }); + }); }); diff --git a/packages/workers-shared/router-worker/wrangler.jsonc b/packages/workers-shared/router-worker/wrangler.jsonc index 33d62792427e..e89f322ed55d 100644 --- a/packages/workers-shared/router-worker/wrangler.jsonc +++ b/packages/workers-shared/router-worker/wrangler.jsonc @@ -25,6 +25,12 @@ "type": "param", "param": "routerConfig", }, + { + "name": "EYEBALL_CONFIG", + "type": "param", + "param": "eyeballRouterConfig", + "optional": true, + }, { "name": "ASSET_WORKER", "type": "internal_assets", diff --git a/packages/workers-shared/utils/types.ts b/packages/workers-shared/utils/types.ts index 9bcfefdb9c30..1cd4374b3c11 100644 --- a/packages/workers-shared/utils/types.ts +++ b/packages/workers-shared/utils/types.ts @@ -20,6 +20,13 @@ export const RouterConfigSchema = z.object({ ...InternalConfigSchema.shape, }); +export const EyeballRouterConfigSchema = z.union([ + z.object({ + limitedAssetsOnly: z.boolean().optional(), + }), + z.null(), +]); + const MetadataStaticRedirectEntry = z.object({ status: z.number(), to: z.string(), @@ -79,6 +86,7 @@ export const AssetConfigSchema = z.object({ ...InternalConfigSchema.shape, }); +export type EyeballRouterConfig = z.infer; export type RouterConfig = z.infer; export type AssetConfig = z.infer;