[cdn] fix some range issues where delivered images might be corrupt

This commit is contained in:
Devin Zuczek
2026-08-10 13:56:32 -04:00
parent 6c7a634cb6
commit 793b2ad37a
6 changed files with 208 additions and 44 deletions
+39 -5
View File
@@ -3,7 +3,7 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { withCleanSpec, withNotFound, withOnError, writeContentRange } from '@repo/hono-helpers'
import { imageBytes, json, ServiceStatus } from './openapi'
@@ -373,6 +373,12 @@ app.get(
'return JPEG with no `ETag` (the source etag no longer describes the body), and the',
'`If-None-Match` precondition is skipped. An out-of-range or non-integer dimension is',
'ignored and the original is served — never an error.',
'',
'A `Range` is honoured (206) only on the untouched stream, which is the only response',
'that advertises `Accept-Ranges`. A transform decodes the whole image and a real',
'signature covers the whole body, so those serve the entire result and ignore the',
'header. Where a range does apply, a `bytes=` request is never answered with a bare',
'200: the `Content-Range` always states which bytes the body holds.',
].join('\n'),
parameters: [
{
@@ -433,9 +439,22 @@ app.get(
'Conditional request against the R2 object etag. Ignored when a transform is requested.',
schema: { type: 'string' },
},
{
name: 'Range',
in: 'header',
required: false,
description: [
'A single byte range, parsed by R2 itself. Honoured with a 206 on the untouched',
'stream only — ignored when a transform or a real signature applies, since both',
'need the whole image. A `bytes=` value never yields a bare 200: the',
'`Content-Range` names the bytes enclosed even where that is all of them.',
].join(' '),
schema: { type: 'string', example: 'bytes=0-1023' },
},
],
responses: {
200: imageBytes('The image bytes (or the DefaultProfileImage.jpg fallback)'),
206: imageBytes('A byte range of the stored image, when the request carried a `Range`'),
304: { description: 'If-None-Match matched the stored object etag; no body' },
400: { description: 'The key contained `..`; no body' },
},
@@ -468,10 +487,16 @@ app.get(
// one. Skip the precondition when a transform is requested.
const ifNoneMatch = transform ? undefined : c.req.header('if-none-match')?.replace(/"/g, '')
const { bucket, objectKey } = resolveObject(c.env, key)
const object = await bucket.get(
objectKey,
ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : undefined
)
// A `Range` applies only to the untouched stream. Resizing decodes the whole image
// and an RSA signature covers the whole body, so a ranged read there would produce
// bytes that are not the range asked for — ask R2 for the range only when we are
// going to hand its bytes straight back. R2 parses the header itself; see
// writeContentRange() below for why it is never answered with a bare 200.
const range = needsBody(transform, signing) ? undefined : c.req.raw.headers
const object = await bucket.get(objectKey, {
...(ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : {}),
...(range ? { range } : {}),
})
if (!object) {
// Missing from both static and R2 → serve the bundled DefaultProfileImage.jpg
// static asset so clients still get a valid image instead of a 404. Honour
@@ -497,6 +522,15 @@ app.get(
return finalizeImage(c.env, bytes, headers, transform, signing)
}
// Only the untouched stream can honour a range, so only it advertises the fact.
// The transformed and static-asset paths above serve the whole thing regardless,
// which is the legal answer to a range you cannot honour — but claiming
// `accept-ranges` there would invite a client to expect otherwise.
headers.set('accept-ranges', 'bytes')
if (writeContentRange(headers, c.req.raw.headers, object)) {
return new Response(object.body, { status: 206, headers })
}
return new Response(object.body, { headers })
}
)
+56
View File
@@ -140,6 +140,62 @@ describe('img endpoints', () => {
expect(res.status).toBe(304)
})
it('honors a Range request on the stored image with a 206', async () => {
const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, { headers: { Range: 'bytes=2-4' } })
expect(res.status).toBe(206)
expect(res.headers.get('content-range')).toBe('bytes 2-4/8')
expect(res.headers.get('accept-ranges')).toBe('bytes')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES.slice(2, 5))
})
// R2 resolves a range it cannot parse or satisfy to the WHOLE object rather than
// failing. Handing that back as a bare 200 is the shape that corrupts a chunked
// download — the client wrote a whole file where it expected a slice — so every one
// of these still states what the body holds.
it('never answers a bytes range with a whole-object 200', async () => {
for (const range of ['bytes=100-200', 'bytes=abc', 'bytes=0-1,3-4', 'bytes=0-7']) {
const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, { headers: { Range: range } })
expect(res.status, range).toBe(206)
expect(res.headers.get('content-range'), range).toBe('bytes 0-7/8')
}
// A unit other than bytes must be ignored outright (RFC 9110), not answered with
// a byte-denominated Content-Range.
const other = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, { headers: { Range: 'items=0-1' } })
expect(other.status).toBe(200)
expect(other.headers.get('content-range')).toBeNull()
})
// A resize decodes the whole image, so there is no meaningful slice of the source to
// read — the range is ignored and the whole transformed result served, which is the
// legal answer. What it must NOT do is claim a 206 over bytes it rebuilt. Runs against
// the R2 path (a decodable JPEG borrowed from `static/`), since that is the one that
// has a range to suppress; the static-asset path is never handed one at all.
it('ignores a Range when a transform rebuilds the body', async () => {
const real = await (await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)).arrayBuffer()
await env.IMAGES.put('ranged-transform.jpg', real, {
httpMetadata: { contentType: 'image/jpeg' },
})
const res = await SELF.fetch(`${ORIGIN}/ranged-transform.jpg?width=128`, {
headers: { Range: 'bytes=0-9' },
})
expect(res.status).toBe(200)
expect(res.headers.get('content-range')).toBeNull()
expect(res.headers.get('accept-ranges')).toBeNull()
expect(jpegSize(new Uint8Array(await res.arrayBuffer())).width).toBe(128)
// Same for a real RSA signature, which covers the whole body (the test env binds
// IMG_SIGNING_ENABLED on, so `?sig=p1` takes the signing path rather than the stub).
const signed = await SELF.fetch(`${ORIGIN}/ranged-transform.jpg?sig=p1`, {
headers: { Range: 'bytes=0-9' },
})
expect(signed.status).toBe(200)
expect(signed.headers.get('content-range')).toBeNull()
expect(signed.headers.get('content-signature')).toContain('key-id=KEY:RSA:p1.rec.net')
expect(new Uint8Array(await signed.arrayBuffer()).byteLength).toBe(real.byteLength)
})
it('serves the DefaultProfileImage.jpg fallback for a missing image', async () => {
const res = await SELF.fetch(`${ORIGIN}/missing.png`)
expect(res.status).toBe(200)