File size: 3,879 Bytes
3464008 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 | export const config = { runtime: 'edge' };
import { jsonResponse } from '../_json-response.js';
import { checkRateLimit } from '../_rate-limit.js';
const MAX_REPORT_BYTES = 32 * 1024;
const MAX_REPORT_ITEMS = 20;
const RESPONSE_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Cache-Control': 'no-store',
};
function isSupportedContentType(value) {
const type = value.split(';', 1)[0]?.trim().toLowerCase();
return type === 'application/reports+json' || type === 'application/report+json' || type === 'application/json';
}
function safeOrigin(value) {
if (typeof value !== 'string' || !value) return undefined;
try {
return new URL(value).origin;
} catch {
return undefined;
}
}
function shortString(value, max = 120) {
if (typeof value !== 'string') return undefined;
return value.length > max ? `${value.slice(0, max)}...` : value;
}
function summarizeReportItem(item) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return { malformed: true };
}
const body = item.body && typeof item.body === 'object' && !Array.isArray(item.body)
? item.body
: {};
return {
type: shortString(item.type, 80),
age: typeof item.age === 'number' ? item.age : undefined,
urlOrigin: safeOrigin(item.url),
bodyType: shortString(body.type, 80),
disposition: shortString(body.disposition, 40),
effectivePolicy: shortString(body.effectivePolicy, 120),
blockedURLOrigin: safeOrigin(body.blockedURL),
destination: shortString(body.destination, 80),
};
}
function summarizeReports(payload) {
const reports = Array.isArray(payload) ? payload : [payload];
return {
count: reports.length,
truncated: reports.length > MAX_REPORT_ITEMS,
reports: reports.slice(0, MAX_REPORT_ITEMS).map(summarizeReportItem),
};
}
async function readBodyWithLimit(req) {
const contentLength = Number(req.headers.get('content-length') ?? 0);
if (Number.isFinite(contentLength) && contentLength > MAX_REPORT_BYTES) {
throw new Error('payload_too_large');
}
if (!req.body) return '';
const reader = req.body.getReader();
const chunks = [];
let total = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_REPORT_BYTES) {
await reader.cancel();
throw new Error('payload_too_large');
}
chunks.push(value);
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(bytes);
}
export default async function handler(req) {
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: RESPONSE_HEADERS });
}
if (req.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed' }, 405, RESPONSE_HEADERS);
}
const limited = await checkRateLimit(req, RESPONSE_HEADERS);
if (limited) return limited;
if (!isSupportedContentType(req.headers.get('content-type') ?? '')) {
return jsonResponse({ error: 'Unsupported media type' }, 415, RESPONSE_HEADERS);
}
let text;
try {
text = await readBodyWithLimit(req);
} catch (err) {
if (err instanceof Error && err.message === 'payload_too_large') {
return jsonResponse({ error: 'Payload too large' }, 413, RESPONSE_HEADERS);
}
return jsonResponse({ error: 'Invalid request body' }, 400, RESPONSE_HEADERS);
}
let payload;
try {
payload = JSON.parse(text);
} catch {
return jsonResponse({ error: 'Invalid JSON' }, 400, RESPONSE_HEADERS);
}
console.info('[security/report]', JSON.stringify(summarizeReports(payload)));
return new Response(null, { status: 204, headers: RESPONSE_HEADERS });
}
|