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
2 changes: 2 additions & 0 deletions docs/adr/0004-pluggable-storage-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ not preclude it.
so Metadata deliveries carry no `contentType`.
It sets `Content-Disposition` on `stream` and `path` deliveries; a
redirecting backend bakes `context.contentDisposition` into its URL.
- The library also sets protective headers on `stream`, `path` and `json`
deliveries; see [ADR 0005](0005-library-sandboxes-delivered-bytes.md).
- Storage is the source of truth for size, ETag and Last-Modified, so a
stale `File.size` can't produce a wrong header. `File.mediaType` is the
source of truth for Content-Type, since stored types are often
Expand Down
59 changes: 59 additions & 0 deletions docs/adr/0005-library-sandboxes-delivered-bytes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# 5. The library sandboxes the bytes it delivers

Date: 2026-10-28

## Status

Accepted

## Context

Content and much Metadata come from depositors and nobody vets them. The
library served them on the API's origin under their own Content-Type and with
no other protective headers. So opening a deposited HTML or SVG file ran its
scripts on that origin, where they could reach cookies, same-origin pages and
any sign-in or admin UI the deployment hosts there.

Browsers render inline PDFs, images and text under the full policy
`default-src 'none'; style-src 'unsafe-inline'; sandbox`. That holds in
current Chrome and Firefox, and in Safari from 18.4; Safari 17.4.1 to 18.3
won't show a PDF under `sandbox`. Chrome won't play audio or
video opened directly under `sandbox`, because the media document refetches
the file from an opaque origin and fails CORS. It also won't play them under
`default-src 'none'` without `media-src`. A browser can't run script in a
media document, and `<audio>`/`<video>` elements ignore the media response's
CSP.

## Decision

Every response the library sends from a `stream`, `path` or `json` delivery,
for Content and Metadata alike, on GET and HEAD, carries:

- `X-Content-Type-Options: nosniff`
- unless its Content-Type is a single `audio/*` or `video/*` type, as a
browser reads the last of a comma-separated list:
`Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox`

`sandbox` gives the document an opaque origin and blocks its scripts.
`default-src 'none'` stops a deposited page loading outside images, fonts or
stylesheets, so it can't pass as a convincing page on the API origin or
beacon out; inline styles still render. `nosniff` keeps a file labelled as
media from being sniffed into HTML.

The headers are fixed: there's no plugin option. A deployment that needs
deposited HTML to run overrides them in its own Fastify `onSend` hook.

Redirect deliveries get none of this, as their headers can't reach the
target.

## Consequences

- Deposited HTML and SVG still display but run no scripts and load nothing
from outside. Images and fonts embedded in them as `data:` URLs are blocked
too.
- Opening audio or video directly plays in every browser, with no CSP
protecting the media document.
- Safari before 18.4 downloads or fails to show inline PDFs.
- A redirect target must protect itself: it should be on an origin that holds
no sign-ins, or send the same headers itself. A presigned S3 URL is already
on its own origin.
107 changes: 106 additions & 1 deletion src/routes/file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import type { FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest';
import { mockReset } from 'vitest-mock-extended';
import type { File } from '../generated/prisma/client.js';
import type { ContentDelivery, ContentSource, MetadataSource } from '../storage/backend.js';
import { prisma } from '../test/helpers/fastify.js';
import { getAndHead } from '../test/helpers/getAndHead.js';
import { mediaOnly, protectiveHeaders, sandboxed, unprotected } from '../test/helpers/protectiveHeaders.js';
import { entityRow, fileRow } from '../test/helpers/rows.js';
import { buildStorageApp } from '../test/helpers/storageApp.js';
import type { StandardErrorResponse } from '../utils/errors.js';
Expand Down Expand Up @@ -142,6 +143,110 @@ describe('File Route', () => {
});
});

describe('protective headers', () => {
const withType = (type: 'stream' | 'path', contentType: string): ContentDelivery =>
type === 'stream'
? { type, open: () => Readable.from([bytes]), contentType, contentLength: bytes.length }
: { type, path: join(dir, 'test.wav'), contentType, contentLength: bytes.length };

describe.each(['stream', 'path'] as const)('%s delivery', (type) => {
it.each([
'text/html',
'image/svg+xml',
'application/pdf',
'application/octet-stream',
// Browsers take the last type in a list, so these must not pass as media
'audio/mpeg, text/html',
'audio/mpeg;x=1, text/html',
'video/mp4,text/html',
' audio/mpeg',
'audio/',
'audio/mpeg; x="a", text/html',
])('sandboxes %s on GET and HEAD', async (contentType) => {
content.mockImplementation(() => withType(type, contentType));

const { get, head } = await both();

for (const response of [get, head]) {
expect(response.statusCode).toBe(200);
expect(protectiveHeaders(response)).toEqual(sandboxed);
}
});

it.each([
'audio/wav',
'video/mp4',
'audio/ogg; codecs=opus',
'Video/MP4',
'AUDIO/Mpeg;rate=44100',
'video/mp4; codecs="avc1.42E01E, mp4a.40.2"',
'audio/ogg; codecs="opus,vorbis"',
])('sends %s with nosniff and no CSP on GET and HEAD', async (contentType) => {
content.mockImplementation(() => withType(type, contentType));

const { get, head } = await both();

for (const response of [get, head]) {
expect(response.statusCode).toBe(200);
expect(protectiveHeaders(response)).toEqual(mediaOnly);
}
});
});

it.each(['', '?noRedirect=true'])('adds neither to a redirect delivery when the query is %j', async (query) => {
content.mockResolvedValue({ type: 'redirect', url: 'https://storage.example.com/test.html' });

const { get, head } = await both(query);

for (const response of [get, head]) {
expect(protectiveHeaders(response)).toEqual(unprotected);
}
});

it('adds neither to a 404 or a 500', async () => {
content.mockResolvedValueOnce(null).mockRejectedValueOnce(new Error('Storage is unreachable'));

const missing = await app.inject({ method: 'GET', url });
const failed = await app.inject({ method: 'GET', url });

expect(missing.statusCode).toBe(404);
expect(failed.statusCode).toBe(500);
for (const response of [missing, failed]) {
expect(protectiveHeaders(response)).toEqual(unprotected);
}
});

it('lets a consumer onSend hook replace the CSP', async () => {
const custom = await buildStorageApp({ metadata, content }, (instance) => {
instance.addHook('onSend', async (_request, reply, payload) => {
if (reply.hasHeader('content-security-policy')) {
reply.header('Content-Security-Policy', 'sandbox allow-scripts');
}
return payload;
});
});
content.mockImplementation(() => withType('stream', 'text/html'));

onTestFinished(() => custom.app.close());

const response = await custom.app.inject({ method: 'GET', url });

expect(protectiveHeaders(response)).toEqual({ csp: 'sandbox allow-scripts', nosniff: 'nosniff' });
});

it('adds neither to the 500 when Last-Modified cannot be set', async () => {
content.mockReturnValue({
...withType('stream', 'text/html'),
lastModified: '2024-01-01' as unknown as Date,
} as ContentDelivery);

const response = await app.inject({ method: 'GET', url });

expect(response.statusCode).toBe(500);
expect(protectiveHeaders(response)).toEqual(unprotected);
});
});

it.each([
['json', { type: 'json', value: {}, contentType: 'application/json' }],
['unknown', { type: 'inline', contentType: 'audio/wav', contentLength: 1 }],
Expand Down
35 changes: 35 additions & 0 deletions src/routes/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { mockReset } from 'vitest-mock-extended';
import type { ContentSource, MetadataDelivery, MetadataSource } from '../storage/backend.js';
import { prisma } from '../test/helpers/fastify.js';
import { getAndHead } from '../test/helpers/getAndHead.js';
import { protectiveHeaders, sandboxed, unprotected } from '../test/helpers/protectiveHeaders.js';
import { entityRow } from '../test/helpers/rows.js';
import { buildStorageApp } from '../test/helpers/storageApp.js';
import type { StandardErrorResponse } from '../utils/errors.js';
Expand Down Expand Up @@ -153,6 +154,40 @@ describe('Metadata Route', () => {
});
});

describe('protective headers', () => {
it.each<[string, () => MetadataDelivery]>([
['json', () => ({ type: 'json', value: { name: 'a' } })],
['stream', () => ({ type: 'stream', open: () => Readable.from([crate]), contentLength: crate.length })],
])('sandboxes a %s delivery on GET and HEAD', async (_, delivery) => {
metadata.mockImplementation(delivery);

const { get, head } = await both();

for (const response of [get, head]) {
expect(response.statusCode).toBe(200);
expect(protectiveHeaders(response)).toEqual(sandboxed);
}
});

it('adds neither to a redirect delivery, a 404 or a 500', async () => {
metadata
.mockResolvedValueOnce({ type: 'redirect', url: 'https://storage.example.com/ro-crate-metadata.json' })
.mockResolvedValueOnce(null)
.mockRejectedValueOnce(new Error('Storage is unreachable'));

const responses = [
await app.inject({ method: 'GET', url }),
await app.inject({ method: 'GET', url }),
await app.inject({ method: 'GET', url }),
];

expect(responses.map((response) => response.statusCode)).toEqual([302, 404, 500]);
for (const response of responses) {
expect(protectiveHeaders(response)).toEqual(unprotected);
}
});
});

it('answers 404 when the half returns null', async () => {
metadata.mockReturnValue(null);

Expand Down
6 changes: 3 additions & 3 deletions src/storage/deliver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
import { open } from 'node:fs/promises';
import type { Readable } from 'node:stream';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { setFileHeaders } from '../utils/headers.js';
import { setDeliveryHeaders } from '../utils/headers.js';
import type { ContentDelivery, JsonDelivery } from './backend.js';

type SendOptions = {
Expand Down Expand Up @@ -34,7 +34,7 @@ export const sendDelivery = async (
if (delivery.type === 'json' && json) {
const body = Buffer.from(JSON.stringify(delivery.value));
const etag = `"${createHash('sha256').update(body).digest('base64url')}"`;
setFileHeaders(reply, { contentType: delivery.contentType, contentLength: body.length, etag });
setDeliveryHeaders(reply, { contentType: delivery.contentType, contentLength: body.length, etag });

return reply.code(200).send(head ? undefined : body);
}
Expand All @@ -52,7 +52,7 @@ export const sendDelivery = async (
}

try {
setFileHeaders(reply, delivery);
setDeliveryHeaders(reply, delivery);
} catch (error) {
body?.destroy();
throw error;
Expand Down
13 changes: 13 additions & 0 deletions src/test/helpers/protectiveHeaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { LightMyRequestResponse } from 'fastify';

/**
* The protective headers a response carries, for comparing with `sandboxed`, `mediaOnly` or `unprotected`
*/
export const protectiveHeaders = (response: LightMyRequestResponse) => ({
csp: response.headers['content-security-policy'],
nosniff: response.headers['x-content-type-options'],
});

export const sandboxed = { csp: "default-src 'none'; style-src 'unsafe-inline'; sandbox", nosniff: 'nosniff' };
export const mediaOnly = { csp: undefined, nosniff: 'nosniff' };
export const unprotected = { csp: undefined, nosniff: undefined };
22 changes: 13 additions & 9 deletions src/test/helpers/storageApp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Writable } from 'node:stream';
import Fastify from 'fastify';
import Fastify, { type FastifyInstance } from 'fastify';
import { vi } from 'vitest';
import type { Options } from '../../app.js';
import app, { AllPublicAccess, AllVisibleEntityFilter, AllVisibleSearchFilter } from '../../app.js';
Expand All @@ -9,8 +9,9 @@ import { prisma } from './fastify.js';

/**
* The plugin over the mocked Prisma client, with `storage` and a log of every error it writes
* `configure` runs on an encapsulated scope, before the plugin is registered in it
*/
export const buildStorageApp = async (storage: Options['storage']) => {
export const buildStorageApp = async (storage: Options['storage'], configure?: (instance: FastifyInstance) => void) => {
type LogLine = { err?: { message: string } };
const errors: LogLine[] = [];
const stream = new Writable({
Expand All @@ -22,13 +23,16 @@ export const buildStorageApp = async (storage: Options['storage']) => {
const access = vi.fn<AccessFunction>(AllPublicAccess);

const instance = Fastify({ logger: { level: 'error', stream } });
await instance.register(app, {
prisma,
search: fakeSearchBackend().backend,
access,
entityVisibilityFilter: AllVisibleEntityFilter,
searchVisibilityFilter: AllVisibleSearchFilter,
storage,
await instance.register(async (scope) => {
configure?.(scope);
await scope.register(app, {
prisma,
search: fakeSearchBackend().backend,
access,
entityVisibilityFilter: AllVisibleEntityFilter,
searchVisibilityFilter: AllVisibleSearchFilter,
storage,
});
});
await instance.ready();

Expand Down
24 changes: 22 additions & 2 deletions src/utils/headers.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,36 @@
import type { FastifyReply } from 'fastify';

export const setFileHeaders = (
/** Sandboxes deposited bytes; see docs/adr/0005-library-sandboxes-delivered-bytes.md */
const contentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; sandbox";

const token = "[!#$%&'*+\\-.^_`|~0-9a-z]+";
const quotedString = '"(?:[^"\\\\]|\\\\.)*"';
const mediaType = new RegExp(
`^(?:audio|video)/${token}(?:[ \\t]*;[ \\t]*${token}=(?:${token}|${quotedString}))*[ \\t]*$`,
'i',
);

// Chrome won't play media opened directly under this CSP, and a media document can't run script.
// RFC 9110 grammar, so no comma outside quotes: browsers split the header as a list, respecting
// quotes, and take the last type, so `audio/mpeg, text/html` is HTML
const isMedia = (contentType: string) => mediaType.test(contentType);

export const setDeliveryHeaders = (
reply: FastifyReply,
metadata: { contentType: string; contentLength: number; etag?: string; lastModified?: Date },
) => {
reply.header('Content-Type', metadata.contentType);
reply.header('Content-Length', metadata.contentLength.toString());

if (metadata.etag) {
reply.header('ETag', metadata.etag);
}
if (metadata.lastModified) {
reply.header('Last-Modified', metadata.lastModified.toUTCString());
}

// Last, so a throw above can't leave them on the 500
reply.header('X-Content-Type-Options', 'nosniff');
if (!isMedia(metadata.contentType)) {
reply.header('Content-Security-Policy', contentSecurityPolicy);
}
};
21 changes: 21 additions & 0 deletions website/examples/configuration/storage-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import arocapi, { type Options } from 'arocapi';
import Fastify from 'fastify';

// Your arocapi options
declare const options: Options;

const fastify = Fastify();

// In its own scope with arocapi, so the hook sees only arocapi's routes
await fastify.register(async (scope) => {
// Let deposited HTML run scripts, still in an opaque origin
scope.addHook('onSend', async (_request, reply, payload) => {
if (reply.hasHeader('content-security-policy')) {
reply.header('Content-Security-Policy', 'sandbox allow-scripts');
}

return payload;
});

await scope.register(arocapi, options);
});
Loading
Loading