Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions apps/web/src/features/journal/images.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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');
Expand All @@ -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();
Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/features/journal/services/image-response.test.ts
Original file line number Diff line number Diff line change
@@ -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();
},
);
11 changes: 8 additions & 3 deletions apps/web/src/features/journal/services/image-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,12 @@ const readBoundedImage = async (request: Request): Promise<Uint8Array> => {
};

/** 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({
Expand All @@ -69,9 +70,13 @@ export const uploadImageResponse = async (
request: Request,
): Promise<Response> => {
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 });
Expand Down
Loading