recorder.js CORS header implementation. Closes #4426

This commit is contained in:
Francis Cao committed 2026-08-04 15:34:36 -07:00
1 parent 80d02254ae
commit 9e0034781a
7 files changed
+189 -17

No files matched your search

+13
View File
@@ -7,6 +7,7 @@ export const config = {
};
const TRACKER_PATH = '/script.js';
const RECORDER_PATH = '/recorder.js';
const COLLECT_PATH = '/api/send';
const LOGIN_PATH = '/login';
const BASE_PATH = process.env.BASE_PATH || '';
@@ -62,6 +63,17 @@ function customScriptUrl(request: NextRequest) {
}
}
function applyStaticScriptHeaders(request: NextRequest, response: NextResponse) {
if (
matchesConfiguredPath(request.nextUrl.pathname, TRACKER_PATH, BASE_PATH) ||
matchesConfiguredPath(request.nextUrl.pathname, RECORDER_PATH, BASE_PATH)
) {
Object.entries(trackerHeaders).forEach(([key, value]) => {
response.headers.set(key, value);
});
}
}
function disableLogin(request: NextRequest) {
const loginDisabled = process.env.DISABLE_LOGIN;
@@ -83,6 +95,7 @@ export default function middleware(req: NextRequest) {
}
res ??= NextResponse.next();
applyStaticScriptHeaders(req, res);
// Set the CSP here, not only at build time in next.config.ts, so
// ALLOWED_FRAME_URLS is resolved from the runtime environment.
+5
View File
@@ -6,6 +6,7 @@ import { getContentSecurityPolicy } from './src/lib/csp';
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
const TRACKER_SCRIPT = '/script.js';
const RECORDER_SCRIPT = '/recorder.js';
const isProd = process.env.NODE_ENV === 'production';
@@ -99,6 +100,10 @@ if (isProd) {
source: TRACKER_SCRIPT,
headers: trackerHeaders,
});
headers.push({
source: RECORDER_SCRIPT,
headers: trackerHeaders,
});
}
const rewrites = [];
+67
View File
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { parseRequest } from '@/lib/request';
import { OPTIONS, POST } from './route';
vi.mock('@/lib/detect', () => ({
getClientInfo: vi.fn(),
hasBlockedIp: vi.fn(),
}));
vi.mock('@/lib/jwt', () => ({
parseToken: vi.fn(),
}));
vi.mock('@/lib/request', () => ({
parseRequest: vi.fn(),
}));
vi.mock('@/queries/prisma', () => ({
getWebsite: vi.fn(),
}));
vi.mock('@/queries/sql', () => ({
saveRecording: vi.fn(),
}));
vi.mock('@/queries/sql/heatmap/saveHeatmapEvents', () => ({
saveHeatmapEvents: vi.fn(),
}));
const parseRequestMock = vi.mocked(parseRequest);
beforeEach(() => {
vi.clearAllMocks();
});
describe('record route CORS', () => {
test('handles preflight requests', async () => {
const response = OPTIONS();
expect(response.status).toBe(204);
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
expect(response.headers.get('Access-Control-Allow-Headers')).toContain('x-umami-cache');
expect(response.headers.get('Access-Control-Allow-Methods')).toContain('POST');
});
test('includes CORS headers on post responses', async () => {
parseRequestMock.mockResolvedValue({
body: {
type: 'record',
payload: {
website: '11111111-1111-4111-8111-111111111111',
events: [],
},
},
error: undefined,
});
const response = await POST(
new Request('http://localhost/api/record', {
method: 'POST',
}),
);
expect(response.status).toBe(200);
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
});
});
+23 -16
View File
@@ -2,6 +2,7 @@ import { isbot } from 'isbot';
import { serializeError } from 'serialize-error';
import { z } from 'zod';
import { HEATMAP_EVENT_TYPE } from '@/lib/constants';
import { corsPreflight, withCorsHeaders } from '@/lib/cors';
import { secret } from '@/lib/crypto';
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
import { parseToken } from '@/lib/jwt';
@@ -96,22 +97,28 @@ async function getRequestBodySize(request: Request): Promise<number | null> {
}
}
export function OPTIONS() {
return corsPreflight();
}
export async function POST(request: Request) {
try {
const requestBodySize = await getRequestBodySize(request);
if (requestBodySize && requestBodySize > MAX_RECORD_REQUEST_BYTES) {
return payloadTooLarge({
return withCorsHeaders(
payloadTooLarge({
reason: 'payload_too_large',
maxBytes: MAX_RECORD_REQUEST_BYTES,
size: requestBodySize,
});
}),
);
}
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
if (error) {
return error();
return withCorsHeaders(error());
}
const { website: websiteId } = body.payload;
@@ -119,20 +126,20 @@ export async function POST(request: Request) {
const timestamp = body.payload.timestamp;
if (!events?.length) {
return json({ ok: true });
return withCorsHeaders(json({ ok: true }));
}
// Parse cache token to get session info
const cacheHeader = request.headers.get('x-umami-cache');
if (!cacheHeader) {
return badRequest({ message: 'Missing session token.' });
return withCorsHeaders(badRequest({ message: 'Missing session token.' }));
}
const cache = (await parseToken(cacheHeader, secret())) as Cache | null;
if (!cache?.sessionId || !cache?.visitId) {
return badRequest({ message: 'Invalid session token.' });
return withCorsHeaders(badRequest({ message: 'Invalid session token.' }));
}
const { sessionId, visitId } = cache;
@@ -141,7 +148,7 @@ export async function POST(request: Request) {
const website = await getWebsite(websiteId);
if (!website) {
return badRequest({ message: 'Website not found.' });
return withCorsHeaders(badRequest({ message: 'Website not found.' }));
}
const recorderConfig = getRecorderConfig(website.replayConfig);
@@ -149,7 +156,7 @@ export async function POST(request: Request) {
const heatmapEnabled = recorderConfig.heatmapEnabled === true;
if (!website.recorderEnabled) {
return json({ ok: false, reason: 'recorder_disabled' });
return withCorsHeaders(json({ ok: false, reason: 'recorder_disabled' }));
}
if (process.env.CLOUD_MODE) {
@@ -160,7 +167,7 @@ export async function POST(request: Request) {
: null;
if (!account?.isBusiness && !account?.isNoBilling) {
return forbidden({ message: 'Business subscription required.' });
return withCorsHeaders(forbidden({ message: 'Business subscription required.' }));
}
}
@@ -168,16 +175,16 @@ export async function POST(request: Request) {
const { ip, userAgent } = await getClientInfo(request, {});
if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) {
return json({ beep: 'boop' });
return withCorsHeaders(json({ beep: 'boop' }));
}
if (hasBlockedIp(ip)) {
return forbidden();
return withCorsHeaders(forbidden());
}
if (body.type === 'record') {
if (!replayEnabled) {
return json({ ok: false, reason: 'replay_disabled' });
return withCorsHeaders(json({ ok: false, reason: 'replay_disabled' }));
}
const eventTimestamps = events
@@ -203,11 +210,11 @@ export async function POST(request: Request) {
endedAt,
});
return json({ ok: true });
return withCorsHeaders(json({ ok: true }));
}
if (!heatmapEnabled) {
return json({ ok: false, reason: 'heatmap_disabled' });
return withCorsHeaders(json({ ok: false, reason: 'heatmap_disabled' }));
}
try {
@@ -238,8 +245,8 @@ export async function POST(request: Request) {
console.log('heatmap save failed', serializeError(e));
}
return json({ ok: true });
return withCorsHeaders(json({ ok: true }));
} catch (e) {
return serverError(e);
return withCorsHeaders(serverError(e));
}
}
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { parseRequest } from '@/lib/request';
import { getWebsite } from '@/queries/prisma';
import { GET, OPTIONS } from './route';
vi.mock('@/lib/request', () => ({
parseRequest: vi.fn(),
}));
vi.mock('@/queries/prisma', () => ({
getWebsite: vi.fn(),
}));
const parseRequestMock = vi.mocked(parseRequest);
const getWebsiteMock = vi.mocked(getWebsite);
beforeEach(() => {
vi.clearAllMocks();
parseRequestMock.mockResolvedValue({ error: undefined });
});
describe('recorder config route CORS', () => {
test('handles preflight requests', async () => {
const response = OPTIONS();
expect(response.status).toBe(204);
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
expect(response.headers.get('Access-Control-Allow-Headers')).toContain('x-umami-cache');
});
test('includes CORS headers on config responses', async () => {
getWebsiteMock.mockResolvedValue(null as any);
const response = await GET(new Request('http://localhost/api/websites/test/recorder'), {
params: Promise.resolve({ websiteId: '11111111-1111-4111-8111-111111111111' }),
});
expect(response.status).toBe(200);
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
expect(response.headers.get('Cache-Control')).toContain('max-age=60');
});
});
@@ -1,7 +1,12 @@
import { corsPreflight, getApiCorsHeaders, withCorsHeaders } from '@/lib/cors';
import { getRecorderConfig } from '@/lib/recorder';
import { parseRequest } from '@/lib/request';
import { getWebsite } from '@/queries/prisma';
export function OPTIONS() {
return corsPreflight();
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
@@ -9,13 +14,14 @@ export async function GET(
const { error } = await parseRequest(request, null, { skipAuth: true });
if (error) {
return error();
return withCorsHeaders(error());
}
const { websiteId } = await params;
const website = await getWebsite(websiteId);
const headers = {
...getApiCorsHeaders(),
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
};
+32
View File
@@ -0,0 +1,32 @@
const DEFAULT_CORS_MAX_AGE = process.env.CORS_MAX_AGE || '86400';
export function getApiCorsHeaders(headers: HeadersInit = {}) {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type, x-umami-cache',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Max-Age': DEFAULT_CORS_MAX_AGE,
...headers,
};
}
export function withCorsHeaders(response: Response, headers: HeadersInit = {}) {
const nextHeaders = new Headers(response.headers);
Object.entries(getApiCorsHeaders(headers)).forEach(([key, value]) => {
nextHeaders.set(key, value);
});
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: nextHeaders,
});
}
export function corsPreflight(headers: HeadersInit = {}) {
return new Response(null, {
status: 204,
headers: getApiCorsHeaders(headers),
});
}