diff --git a/apps/web/src/features/journal/images.test.tsx b/apps/web/src/features/journal/images.test.tsx index dd8102b..9cb0460 100644 --- a/apps/web/src/features/journal/images.test.tsx +++ b/apps/web/src/features/journal/images.test.tsx @@ -99,6 +99,7 @@ it('rejects cross-origin uploads and bounds chunked bodies before buffering', as await Effect.runPromise( readImageUpload( new Request(url, { method: 'POST', headers, body: bytes }), + headers.origin, ), ), ).toEqual(bytes); @@ -111,6 +112,7 @@ it('rejects cross-origin uploads and bounds chunked bodies before buffering', as headers: { ...headers, origin }, body: bytes, }), + headers.origin, ).pipe(Effect.either), ); expect(result._tag).toBe('Left'); @@ -124,9 +126,10 @@ it('rejects cross-origin uploads and bounds chunked bodies before buffering', as }, }); const result = await Effect.runPromise( - readImageUpload(new Request(url, { method: 'POST', headers, body })).pipe( - Effect.either, - ), + readImageUpload( + new Request(url, { method: 'POST', headers, body }), + headers.origin, + ).pipe(Effect.either), ); expect(result._tag).toBe('Left'); expect(cancelled).toBeTrue(); diff --git a/apps/web/src/features/journal/services/image-response.test.ts b/apps/web/src/features/journal/services/image-response.test.ts new file mode 100644 index 0000000..181339c --- /dev/null +++ b/apps/web/src/features/journal/services/image-response.test.ts @@ -0,0 +1,62 @@ +import { expect, it } from 'bun:test'; +import { Effect } from 'effect'; + +import { readImageUpload } from './image-response.ts'; + +const publicOrigin = 'https://journal.example'; +const bytes = new TextEncoder().encode('GIF89a'); + +it('accepts the public HTTPS origin over an internal HTTP connection', async () => { + const request = new Request('http://journal.example/api/journal-images/', { + method: 'POST', + headers: { + origin: publicOrigin, + 'x-postlude-image-upload': 'true', + 'x-forwarded-proto': 'https', + }, + body: bytes, + }); + + expect( + await Effect.runPromise(readImageUpload(request, publicOrigin)), + ).toEqual(bytes); +}); + +it.each([ + { origin: 'https://evil.example', marker: 'true' }, + { origin: 'http://journal.example', marker: 'true' }, + { origin: 'null', marker: 'true' }, + { origin: undefined, marker: 'true' }, + { origin: publicOrigin, marker: undefined }, + { origin: publicOrigin, marker: 'false' }, +])( + 'rejects untrusted origins or missing upload intent: %j', + async ({ origin, marker }) => { + const headers = new Headers({ + host: 'evil.example', + 'x-forwarded-host': 'evil.example', + 'x-forwarded-proto': 'https', + forwarded: 'host=evil.example;proto=https', + }); + if (origin !== undefined) { + headers.set('origin', origin); + } + if (marker !== undefined) { + headers.set('x-postlude-image-upload', marker); + } + const request = new Request('https://evil.example/api/journal-images/', { + method: 'POST', + headers, + body: bytes, + }); + + const result = await Effect.runPromise( + readImageUpload(request, publicOrigin).pipe(Effect.either), + ); + expect(result).toMatchObject({ + _tag: 'Left', + left: { _tag: 'JournalValidationError' }, + }); + expect(request.bodyUsed).toBeFalse(); + }, +); diff --git a/apps/web/src/features/journal/services/image-response.ts b/apps/web/src/features/journal/services/image-response.ts index c8e7004..3cb3a61 100644 --- a/apps/web/src/features/journal/services/image-response.ts +++ b/apps/web/src/features/journal/services/image-response.ts @@ -43,11 +43,12 @@ const readBoundedImage = async (request: Request): Promise => { }; /** Count streamed bytes before buffering so chunked uploads obey the same limit. */ -export const readImageUpload = (request: Request) => +export const readImageUpload = (request: Request, publicOrigin: string) => Effect.tryPromise({ try: async () => { if ( - request.headers.get('origin') !== new URL(request.url).origin || + // TLS terminates at the proxy; the internal request URL can use HTTP. + request.headers.get('origin') !== publicOrigin || request.headers.get('x-postlude-image-upload') !== 'true' ) { throw new JournalValidationError({ @@ -69,9 +70,13 @@ export const uploadImageResponse = async ( request: Request, ): Promise => { const { runJournalEffect } = await import('./journal-runtime.ts'); + const { env } = await import('#/shared/env.ts'); return runJournalEffect( Effect.gen(function* () { - const bytes = yield* readImageUpload(request); + const bytes = yield* readImageUpload( + request, + new URL(env.BETTER_AUTH_URL).origin, + ); const images = yield* JournalImages; const uploaded = yield* images.upload(bytes); return Response.json(uploaded, { headers: privateResponseHeaders });