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 2d7d7c3e7c3e..e14f82ee12f5 100644 --- a/packages/workers-shared/router-worker/src/analytics.ts +++ b/packages/workers-shared/router-worker/src/analytics.ts @@ -35,6 +35,8 @@ type Data = { staticRoutingDecision?: STATIC_ROUTING_DECISION; // double7 - Whether the request was blocked by abuse mitigation or not abuseMitigationBlocked?: boolean; + // double8 - User worker invocation denied due to free tier limiting + userWorkerFreeTierLimiting?: boolean; // -- Blobs -- // blob1 - Hostname of the request @@ -93,6 +95,7 @@ export class Analytics { : Number(this.data.userWorkerAhead), this.data.staticRoutingDecision ?? STATIC_ROUTING_DECISION.NOT_PROVIDED, // double6 this.data.abuseMitigationBlocked ? 1 : 0, // double7 + this.data.userWorkerFreeTierLimiting ? 1 : 0, // double8 ], 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/limited-response.ts b/packages/workers-shared/router-worker/src/limited-response.ts new file mode 100644 index 000000000000..608429d4b20a --- /dev/null +++ b/packages/workers-shared/router-worker/src/limited-response.ts @@ -0,0 +1,1480 @@ +function formatDate(date: Date) { + const formatter = new Intl.DateTimeFormat("en-CA", { + // en-CA for YYYY-MM-DD + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", // Ensures 24-hour format + timeZone: "UTC", + }); + const parts = formatter.formatToParts(date); + let year, month, day, hour, minute, second; + for (const part of parts) { + switch (part.type) { + case "year": + year = part.value; + break; + case "month": + month = part.value; + break; + case "day": + day = part.value; + break; + case "hour": + hour = part.value; + break; + case "minute": + minute = part.value; + break; + case "second": + second = part.value; + break; + } + } + return `${year}-${month}-${day} ${hour}:${minute}:${second} UTC`; +} + +export function renderLimitedResponse(req: Request) { + const hostname = new URL(req.url).hostname; + const ip = req.headers.get("cf-connecting-ip") ?? ""; + const ray = req.headers.get("cf-ray") ?? ""; + const date = formatDate(new Date()); + + return ` + + + + + + + + + + This website has been temporarily rate limited | + ${hostname} | Cloudflare + + + + + + + + + + + + + + + + + +
+ +
+
+

+ Please check back later +

+

Error 1027

+
+ +
+

+ + + + + + + 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. +

+ +

+ + Learn more about this issue → + +

+
+ + + +
+
+ + + +`; +} diff --git a/packages/workers-shared/router-worker/src/worker.ts b/packages/workers-shared/router-worker/src/worker.ts index f053608030c0..551c1c15a5d7 100644 --- a/packages/workers-shared/router-worker/src/worker.ts +++ b/packages/workers-shared/router-worker/src/worker.ts @@ -3,9 +3,14 @@ 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 { renderLimitedResponse } from "./limited-response"; import type AssetWorker from "../../asset-worker"; import type { + EyeballRouterConfig, JaegerTracing, RouterConfig, UnsafePerformanceTimer, @@ -16,6 +21,7 @@ export interface Env { ASSET_WORKER: Service; USER_WORKER: Fetcher; CONFIG: RouterConfig; + EYEBALL_CONFIG: EyeballRouterConfig; SENTRY_DSN: string; ENVIRONMENT: Environment; @@ -55,7 +61,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,23 +84,83 @@ export default { const maybeSecondRequest = request.clone(); - let shouldBlockNonImageResponse = false; - if (url.pathname === "/_next/image") { - // is a next image - const queryURLParam = url.searchParams.get("url"); - if (queryURLParam && !queryURLParam.startsWith("/")) { - // that's a remote resource - if ( - maybeSecondRequest.method !== "GET" || - maybeSecondRequest.headers.get("sec-fetch-dest") !== "image" - ) { - // that was not loaded via a browser's tag - shouldBlockNonImageResponse = true; - analytics.setData({ abuseMitigationURLHost: queryURLParam }); - } - // otherwise, we're good + 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(renderLimitedResponse(maybeSecondRequest), { + 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, + }); + + let shouldBlockNonImageResponse = false; + if (url.pathname === "/_next/image") { + // is a next image + const queryURLParam = url.searchParams.get("url"); + if (queryURLParam && !queryURLParam.startsWith("/")) { + // that's a remote resource + if ( + maybeSecondRequest.method !== "GET" || + maybeSecondRequest.headers.get("sec-fetch-dest") !== "image" + ) { + // that was not loaded via a browser's tag + shouldBlockNonImageResponse = true; + analytics.setData({ abuseMitigationURLHost: queryURLParam }); + } + // otherwise, we're good + } + } + + if (shouldBlockNonImageResponse) { + const resp = await env.USER_WORKER.fetch(maybeSecondRequest); + if ( + !resp.headers.get("content-type")?.startsWith("image/") && + resp.status !== 304 + ) { + analytics.setData({ abuseMitigationBlocked: true }); + return new Response("Blocked", { status: 403 }); + } + return resp; + } + 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 @@ -107,18 +174,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( @@ -136,30 +194,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; - if (shouldBlockNonImageResponse) { - const resp = await env.USER_WORKER.fetch(maybeSecondRequest); - if ( - !resp.headers.get("content-type")?.startsWith("image/") && - resp.status !== 304 - ) { - analytics.setData({ abuseMitigationBlocked: true }); - return new Response("Blocked", { status: 403 }); - } - return resp; - } - return env.USER_WORKER.fetch(maybeSecondRequest); - }); + return await routeToUserWorker({ asset: "static_routing" }); } analytics.setData({ @@ -172,75 +209,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; - if (shouldBlockNonImageResponse) { - const resp = await env.USER_WORKER.fetch(maybeSecondRequest); - if ( - !resp.headers.get("content-type")?.startsWith("image/") && - resp.status !== 304 - ) { - analytics.setData({ abuseMitigationBlocked: true }); - return new Response("Blocked", { status: 403 }); - } - return resp; - } - 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; - if (shouldBlockNonImageResponse) { - const resp = await env.USER_WORKER.fetch(maybeSecondRequest); - if ( - !resp.headers.get("content-type")?.startsWith("image/") && - resp.status !== 304 - ) { - analytics.setData({ abuseMitigationBlocked: true }); - return new Response("Blocked", { status: 403 }); - } - return resp; - } - 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 69f0c1adffa1..3512c30574ec 100644 --- a/packages/workers-shared/router-worker/tests/index.test.ts +++ b/packages/workers-shared/router-worker/tests/index.test.ts @@ -377,4 +377,163 @@ describe("unit tests", async () => { const response = await worker.fetch(request, env, ctx); expect(response.status).toBe(304); }); + + 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;