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
29 changes: 29 additions & 0 deletions apps/web/src/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,35 @@ describe('middleware', () => {
vi.clearAllMocks();
});

describe('Content-Security-Policy', () => {
async function cspFor(path: string): Promise<string> {
const { NextResponse } = await import('next/server');
vi.mocked(updateSession).mockResolvedValue(NextResponse.next());
const response = await middleware(createNextRequest(path));
return response.headers.get('content-security-policy') ?? '';
}

function mediaSrc(csp: string): string {
return csp.split('; ').find((d) => d.startsWith('media-src ')) ?? '';
}

// Regression: media-src allowed a remote origin only on /embed/*, so a
// recording played in the embeddable player but was blocked on the site's
// own replay pages — a silent, console-only failure.
it('allows the recording storage origin on replay pages', async () => {
expect(mediaSrc(await cspFor('/l/TER8XG'))).toContain('supabase');
});

it('allows the same media origins on /embed/* as on the site', async () => {
expect(mediaSrc(await cspFor('/embed/TER8XG'))).toBe(mediaSrc(await cspFor('/l/TER8XG')));
});

it('still restricts frame-ancestors off the embed player', async () => {
expect(await cspFor('/l/TER8XG')).toContain("frame-ancestors 'self' chrome-extension:");
expect(await cspFor('/embed/TER8XG')).toContain('frame-ancestors *');
});
});

describe('CORS preflight', () => {
it('returns 204 with CORS headers for OPTIONS on /api/ routes', async () => {
const request = createNextRequest('/api/sessions/123/signal/stream', 'OPTIONS');
Expand Down
20 changes: 18 additions & 2 deletions apps/web/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,30 @@ import { CORS_HEADERS } from '@/lib/cors';
// can carry a fresh script nonce — that lets us drop 'unsafe-inline' from
// script-src. Next.js reads the nonce from the request's CSP header and applies
// it to its inline bootstrap scripts; our own inline <script>s read x-nonce.

// Session recordings are uploaded to Supabase Storage and streamed straight
// from there by /l/<code> and the embeddable player, so that origin has to be
// in media-src on EVERY page — scoping it to /embed/* left the site's own
// replay pages silently blocking their <video> (nothing plays, console-only
// error). Derived from the same env var that builds the playback URLs, with
// the project wildcard as a fallback so a missing var can't re-break replay.
function mediaOrigin(): string {
const base = process.env.NEXT_PUBLIC_SUPABASE_URL;
if (!base) return 'https://*.supabase.co';
try {
return new URL(base).origin;
} catch {
return 'https://*.supabase.co';
}
}

function buildCsp(nonce: string, embeddable: boolean): string {
return [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'wasm-unsafe-eval' https://crawlproof.com https://datafa.st https://feedback.profullstack.com`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
// The embeddable player streams recordings from Supabase Storage over https.
embeddable ? "media-src 'self' blob: https:" : "media-src 'self' blob:",
`media-src 'self' blob: ${mediaOrigin()}`,
"connect-src 'self' https: wss: https://crawlproof.com",
// /embed/* is the public player — any site may frame it. That is the whole
// point of the surface, and it is safe because the page is read-only: it
Expand Down
Loading