diff --git a/tests/composition-runtime.test.mjs b/tests/composition-runtime.test.mjs new file mode 100644 index 0000000..574c1ca --- /dev/null +++ b/tests/composition-runtime.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; + +// Exercise actual Worker fetch semantics using Wrangler's existing dev tools. +const require=createRequire(import.meta.url); +const tooling=createRequire(require.resolve('wrangler/package.json')); +const {Miniflare,convertV4MiniflareOptions}=tooling('miniflare'); +const {buildSync}=tooling('esbuild'); +const root=fileURLToPath(new URL('../',import.meta.url)); +const script=buildSync({stdin:{contents:"import {compositionMedia} from './worker/src/meme-composition.mjs'; export default {fetch(){return compositionMedia('drake-preference');}}",resolveDir:root},bundle:true,write:false,format:'esm',platform:'browser'}).outputFiles[0].text; + +test('actual Workerd media path serves allowed rasters and never follows redirects',async()=>{ + let calls=0,redirect=false; + const runtime=new Miniflare(convertV4MiniflareOptions({ + name:'caption-media-regression',modules:true,compatibilityDate:'2026-09-01',script, + outboundService:async request=>{ + calls++;assert.equal(request.url,'https://i.imgflip.com/30b1gx.jpg'); + return redirect?new Response(null,{status:302,headers:{Location:'https://private.invalid/'}}): + new Response(new Uint8Array([255,216,255,1]),{headers:{'Content-Type':'image/jpeg'}}); + } + })); + try { + const image=await runtime.dispatchFetch('http://localhost/'); + assert.equal(image.status,200);assert.equal(image.headers.get('content-type'),'image/jpeg'); + assert.deepEqual(new Uint8Array(await image.arrayBuffer()),new Uint8Array([255,216,255,1])); + assert.equal(calls,1); + redirect=true; + assert.equal((await runtime.dispatchFetch('http://localhost/')).status,502); + assert.equal(calls,2,'redirect target must never be fetched'); + }finally{await runtime.dispose();} +}); diff --git a/tests/meme-composition.test.mjs b/tests/meme-composition.test.mjs index 44453bb..643b7bf 100644 --- a/tests/meme-composition.test.mjs +++ b/tests/meme-composition.test.mjs @@ -185,12 +185,15 @@ test('body limit applies to bytes read, even when a caller omits Content-Length' test('media fetch permits only catalogue rasters, rejects redirects and checks actual signatures',async()=>{ let fetched; const response=await compositionMedia(drake.id,async(url,options)=>{fetched={url,options};return new Response(new Uint8Array([255,216,255,1]),{headers:{'Content-Type':'image/jpeg'}});}); - assert.equal(response.status,200);assert.equal(fetched.url,drake.image_url);assert.equal(fetched.options.redirect,'error'); + assert.equal(response.status,200);assert.equal(fetched.url,drake.image_url);assert.equal(fetched.options.redirect,'manual'); assert.equal(response.headers.get('Cross-Origin-Resource-Policy'),'same-origin'); for(const [type,bytes] of [['image/svg+xml',''],['image/jpeg','not an image'],['image/gif','GIF89a']]) { assert.equal((await compositionMedia(drake.id,async()=>new Response(bytes,{headers:{'Content-Type':type}}))).status,502); } assert.equal((await compositionMedia('https://localhost/admin',()=>assert.fail('Must not fetch.'))).status,404); + let redirects=0; + assert.equal((await compositionMedia(drake.id,async()=>{redirects++;return new Response(null,{status:302,headers:{Location:'https://private.invalid/'}});})).status,502); + assert.equal(redirects,1); for(const url of ['https://i.imgflip.com.evil.test/a.jpg','https://user:pass@i.imgflip.com/a.jpg','https://i.imgflip.com:8443/a.jpg','http://i.imgflip.com/a.jpg','https://i.imgflip.com/../admin']) assert.equal(creationMediaUrl({image_url:url}),null); }); diff --git a/worker/src/meme-composition.mjs b/worker/src/meme-composition.mjs index fef4215..5204645 100644 --- a/worker/src/meme-composition.mjs +++ b/worker/src/meme-composition.mjs @@ -87,14 +87,22 @@ function isRaster(bytes,type) { export async function compositionMedia(id,fetchImpl=fetch) { const record=byId.get(id);const url=creationMediaUrl(record); if(!url) return json({error:'Image composition is unavailable for this reference.'},404); + let stage='fetch',upstreamStatus; try { - const response=await fetchImpl(url,{redirect:'error',signal:AbortSignal.timeout(12000)}); + // Workerd rejects redirect:"error". Manual mode keeps redirects unfollowed; + // the response gate below rejects every non-2xx before reading any bytes. + const response=await fetchImpl(url,{redirect:'manual',signal:AbortSignal.timeout(12000)}); + upstreamStatus=response.status;stage='response'; const type=response.headers.get('content-type')?.split(';')[0].toLowerCase(); if(!response.ok||!['image/jpeg','image/png','image/webp'].includes(type)||Number(response.headers.get('content-length'))>MAX_IMAGE_BYTES) {await response.body?.cancel();throw new Error();} - const bytes=await boundedBytes(response.body,MAX_IMAGE_BYTES); + stage='read';const bytes=await boundedBytes(response.body,MAX_IMAGE_BYTES); + stage='signature'; if(!isRaster(bytes,type)) throw new Error(); return new Response(bytes,{headers:{'Content-Type':type,'Cache-Control':'public, max-age=86400','X-Content-Type-Options':'nosniff','Cross-Origin-Resource-Policy':'same-origin','Referrer-Policy':'no-referrer'}}); - } catch {return json({error:'Could not load the source image. Try again shortly.'},502);} + } catch { + console.log(JSON.stringify({event:'composition.media_failed',stage,upstream_status:upstreamStatus})); + return json({error:'Could not load the source image. Try again shortly.'},502); + } } export async function annaComposition(request,env) {