recorder.js CORS header implementation. Closes #4426
This commit is contained in:
1 parent
80d02254ae
commit
9e0034781a
7 files changed
+192
-20
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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('*');
|
||||
});
|
||||
});
|
||||
+26
-19
@@ -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({
|
||||
reason: 'payload_too_large',
|
||||
maxBytes: MAX_RECORD_REQUEST_BYTES,
|
||||
size: requestBodySize,
|
||||
});
|
||||
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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user