diff --git a/apps/img/README.md b/apps/img/README.md index 41f69a8..c930033 100644 --- a/apps/img/README.md +++ b/apps/img/README.md @@ -15,12 +15,25 @@ key: honours `?sig=p1` and returns a `Content-Signature` header. - `GET /?sig=p1` — same, but the response body is RSA-SHA1 signed and the signature returned in a `Content-Signature: key-id=KEY:RSA:p1.rec.net; data=` - header. The client uses this to verify image integrity. Signing buffers the - whole object. + header. The client uses this to verify image integrity. **Off by default** — + see below. The bucket is bound in the Worker as `env.IMAGES` (see `wrangler.jsonc`). -## Response signing key +## Response signing + +Signing is behind the `IMG_SIGNING_ENABLED` var in `wrangler.jsonc`, and it is +**`false`**. It is the worker's dominant CPU cost: signing has to buffer the +whole object into the isolate rather than streaming it out of R2, then hash the +full body with SHA-1 and run an RSA-2048 private-key operation — on every request +the edge cache misses. Nothing verifies the header today, so with the flag off +`?sig=p1` is accepted and ignored, and untransformed images stream through +untouched. + +Flip the var to `true` to turn it back on. (Resizes still buffer regardless — the +Photon codec needs the whole image.) + +### Signing key `?sig=p1` signs with the RSA-2048 key in `env.IMG_SIGNING_KEY` (PKCS8 DER, base64). `wrangler.jsonc` ships an **insecure dev key** for local dev / tests; diff --git a/apps/img/src/context.ts b/apps/img/src/context.ts index 7a7a591..4ca08d5 100644 --- a/apps/img/src/context.ts +++ b/apps/img/src/context.ts @@ -19,6 +19,13 @@ export type Env = SharedHonoEnv & { * requested with `?sig=p1`. Optional — when absent, responses are unsigned. */ IMG_SIGNING_KEY?: string + /** + * Feature flag for response signing. `?sig=p1` is honoured only when this is + * true; when false (the default) the query param is ignored and the response + * streams unsigned. Off by default because signing is this worker's dominant + * CPU cost — see the `?sig=p1` handling in `img.app.ts`. + */ + IMG_SIGNING_ENABLED?: boolean } /** Variables can be extended */ diff --git a/apps/img/src/img.app.ts b/apps/img/src/img.app.ts index 508df0a..68a2547 100644 --- a/apps/img/src/img.app.ts +++ b/apps/img/src/img.app.ts @@ -257,7 +257,8 @@ app.get( '`recflare-img` bucket; extensionless ones are `storage` uploads and come from the', 'shared `recflare-cdn` bucket under its `image/` prefix. Optional center-crop and resize', 'run through the Photon WASM codec; `?sig=p1` adds the RSA-SHA1 `Content-Signature`', - 'header the client verifies against `KEY:RSA:p1.rec.net`.', + 'header the client verifies against `KEY:RSA:p1.rec.net`, when the', + '`IMG_SIGNING_ENABLED` flag is on (it is off by default).', '', 'Note that this worker only serves bytes: the image metadata the client lists (the', '`SavedImage` records behind `/api/images/...`) lives in the `api` worker, which', @@ -345,8 +346,10 @@ app.get( description: [ '`p1` RSA-SHA1 signs the response body and returns it as', '`Content-Signature: key-id=KEY:RSA:p1.rec.net; data=`. Signed over the', - 'bytes actually returned, i.e. the resized body when a transform applies. Omitted', - 'when the worker has no `IMG_SIGNING_KEY`.', + 'bytes actually returned, i.e. the resized body when a transform applies.', + 'Signing is off by default (it costs the streaming fast path): the param is', + 'ignored, and no header returned, unless the worker sets `IMG_SIGNING_ENABLED`', + 'and has an `IMG_SIGNING_KEY`.', ].join(' '), schema: { type: 'string', enum: ['p1'] }, }, @@ -369,7 +372,12 @@ app.get( const key = c.req.param('key') if (key.includes('..')) return c.body(null, 400) - const wantsSignature = c.req.query('sig') === 'p1' + // Signing is this worker's dominant CPU cost: it forces the whole object + // through the isolate (`arrayBuffer()` instead of streaming `object.body`) + // and pays a SHA-1 over the full body plus an RSA-2048 private-key operation + // on every edge-cache miss. Nothing verifies the header today, so `?sig=p1` + // is ignored unless IMG_SIGNING_ENABLED turns it back on. + const wantsSignature = c.env.IMG_SIGNING_ENABLED === true && c.req.query('sig') === 'p1' const transform = parseTransform( c.req.query('width'), c.req.query('height'), diff --git a/apps/img/src/test/integration/api.test.ts b/apps/img/src/test/integration/api.test.ts index c7b4d53..bac9fcd 100644 --- a/apps/img/src/test/integration/api.test.ts +++ b/apps/img/src/test/integration/api.test.ts @@ -1,8 +1,8 @@ import { PhotonImage } from '@cf-wasm/photon' -import { env, SELF } from 'cloudflare:test' +import { createExecutionContext, env, SELF, waitOnExecutionContext } from 'cloudflare:test' import { beforeAll, describe, expect, it } from 'vitest' -import '../../img.app' +import app from '../../img.app' import type { Env } from '../../context' @@ -188,6 +188,25 @@ describe('img endpoints', () => { expect(res.headers.get('content-signature')).toBeNull() }) + it('ignores ?sig=p1 when IMG_SIGNING_ENABLED is off', async () => { + // The deployed default (see wrangler.jsonc): the param is accepted but does + // nothing, so the object streams straight from R2 instead of being buffered, + // hashed and RSA-signed. Vitest binds the flag ON, so override it here. + const ctx = createExecutionContext() + const res = await app.fetch( + new Request(`${ORIGIN}/${R2_KEY}?sig=p1`), + { ...env, IMG_SIGNING_ENABLED: false }, + ctx + ) + await waitOnExecutionContext(ctx) + + expect(res.status).toBe(200) + expect(res.headers.get('content-signature')).toBeNull() + // Unsigned responses keep the source etag, and the body is untouched. + expect(res.headers.get('etag')).toBeTruthy() + expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES) + }) + it('resizes a static asset to ?width, preserving aspect ratio', async () => { const full = new Uint8Array(await (await SELF.fetch(`${ORIGIN}/RecCenter.jpg`)).arrayBuffer()) const original = jpegSize(full) diff --git a/apps/img/vitest.config.ts b/apps/img/vitest.config.ts index de0d903..02b70ce 100644 --- a/apps/img/vitest.config.ts +++ b/apps/img/vitest.config.ts @@ -8,6 +8,10 @@ export default defineConfig({ miniflare: { bindings: { ENVIRONMENT: 'VITEST', + // Signing is off in `wrangler.jsonc`; turn it on here so the `?sig=p1` + // path stays covered. The flag-off behaviour is tested by calling the + // app directly with an overridden env. + IMG_SIGNING_ENABLED: true, }, }, }), diff --git a/apps/img/wrangler.jsonc b/apps/img/wrangler.jsonc index 6822164..8d12589 100644 --- a/apps/img/wrangler.jsonc +++ b/apps/img/wrangler.jsonc @@ -54,6 +54,12 @@ "vars": { "ENVIRONMENT": "development", // overridden during deployment "SENTRY_RELEASE": "unknown", // overridden during deployment + // Feature flag for `?sig=p1` response signing. OFF: signing buffers the whole + // object into the isolate instead of streaming it from R2, then pays a SHA-1 + // over the full body plus an RSA-2048 private-key op on every edge-cache miss. + // Nothing verifies the `Content-Signature` header today. Flip to true (and set + // a real IMG_SIGNING_KEY) if something ever needs to. + "IMG_SIGNING_ENABLED": false, // RSA-2048 private key (PKCS8 DER, base64) used to sign image responses // requested with ?sig=p1. This is an INSECURE DEV KEY committed for local // dev / tests — override in production with `wrangler secret put IMG_SIGNING_KEY`.