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: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"lint": "eslint . --max-warnings 0",
"preview": "next build && next start",
"start": "next start",
"test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/watchlist-sizes.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-compare.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/compare-params.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts",
"test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/watchlist-sizes.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-compare.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/compare-params.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/csp.test.ts",
"typecheck": "tsc --noEmit"
},
"browserslist": [
Expand Down
112 changes: 112 additions & 0 deletions apps/web/src/app/api/csp-report/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { NextResponse } from 'next/server';
import { createLogger } from '@/lib/logger';

const log = createLogger('csp');

// Browsers post a few hundred bytes; anything larger is not a report.
const MAX_REPORT_BYTES = 8 * 1024;
// One log line per distinct violation per window and instance, at most this many
// distinct ones: an endpoint anyone can post to must not flood the logs.
const DEDUPE_WINDOW_MS = 10 * 60 * 1000;
const MAX_DISTINCT = 200;
const seen = new Map<string, number>();

/** The path of a URL, or the scheme/keyword a CSP report gives ("inline", "eval"). */
function shortUrl(value: unknown): string | undefined {
if (typeof value !== 'string' || !value) return undefined;
try {
const url = new URL(value);
return `${url.origin}${url.pathname}`.slice(0, 200);
} catch {
return value.slice(0, 40);
}
}

/** The body as text, or null once it passes `limit` bytes (stops reading there). */
async function readCapped(
request: Request,
limit: number
): Promise<string | null> {
const declared = Number(request.headers.get('content-length') ?? 0);
if (declared > limit) return null;
if (!request.body) return '';
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > limit) {
await reader.cancel();
return null;
}
chunks.push(value);
}
const all = new Uint8Array(size);
let at = 0;
for (const c of chunks) {
all.set(c, at);
at += c.byteLength;
}
return new TextDecoder().decode(all);
}

/** True the first time a key is seen in the window, while the table has room. */
function firstInWindow(key: string, now: number): boolean {
const last = seen.get(key);
if (last !== undefined && now - last < DEDUPE_WINDOW_MS) return false;
if (last === undefined && seen.size >= MAX_DISTINCT) {
for (const [k, t] of seen) {
if (now - t >= DEDUPE_WINDOW_MS) seen.delete(k);
}
if (seen.size >= MAX_DISTINCT) return false;
}
seen.set(key, now);
return true;
}

/**
* POST /api/csp-report: violation reports of the report-only Content Security
* Policy (DOS/Rate#1311). A report needs a directive and a page on this site's
* origin; each distinct one is logged once per window, without query strings, so
* the report window can be read from the logs before the policy is enforced.
* Always 204.
*/
export async function POST(request: Request) {
try {
const text = await readCapped(request, MAX_REPORT_BYTES);
if (!text) return new NextResponse(null, { status: 204 });
const parsed = JSON.parse(text) as {
'csp-report'?: Record<string, unknown>;
};
const report = parsed?.['csp-report'];
if (!report || typeof report !== 'object') {
return new NextResponse(null, { status: 204 });
}
const directive = String(
report['effective-directive'] ?? report['violated-directive'] ?? ''
).slice(0, 60);
const page = shortUrl(report['document-uri']);
const origin = new URL(request.url).origin;
if (!/^[a-z-]+$/.test(directive) || !page?.startsWith(`${origin}/`)) {
return new NextResponse(null, { status: 204 });
}
const blocked = shortUrl(report['blocked-uri']);
const source = shortUrl(report['source-file']);
if (
firstInWindow(`${directive}|${blocked}|${page}|${source}`, Date.now())
) {
log.warn('csp violation', {
directive,
blocked,
page,
source,
line: Number(report['line-number']) || undefined,
});
}
} catch {
// Not a report: nothing to log.
}
return new NextResponse(null, { status: 204 });
}
174 changes: 174 additions & 0 deletions apps/web/src/lib/__tests__/csp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import assert from 'node:assert/strict';

// The report-only Content Security Policy (DOS/Rate#1311): a nonce per request on
// both the request (for Next.js) and the response, a script-src with no inline
// allowance, and a report endpoint that only logs.

process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://abc.supabase.co';

async function main() {
const { buildContentSecurityPolicy, createCspNonce, CSP_HEADER } =
await import('../csp');

const a = createCspNonce();
const b = createCspNonce();
assert.notEqual(a, b, 'a fresh nonce per request');
assert.match(a, /^[A-Za-z0-9+/]{22}==$/, '128 random bits, base64');

const policy = buildContentSecurityPolicy('n0nce', {
supabaseUrl: 'https://abc.supabase.co/rest/v1',
});
const directive = (name: string) =>
policy
.split('; ')
.find((d) => d.startsWith(`${name} `))
?.slice(name.length + 1);
assert.equal(
directive('script-src'),
"'self' 'nonce-n0nce' 'strict-dynamic'",
'scripts: the nonce, never unsafe-inline or unsafe-eval in production'
);
assert.equal(directive('default-src'), "'self'");
assert.equal(
directive('connect-src'),
"'self' https://abc.supabase.co wss://abc.supabase.co"
);
assert.equal(directive('object-src'), "'none'");
assert.equal(directive('frame-ancestors'), "'self'");
assert.equal(directive('report-uri'), '/api/csp-report');
assert.match(
buildContentSecurityPolicy('x', { dev: true }),
/script-src 'self' 'nonce-x' 'strict-dynamic' 'unsafe-eval'/
);
assert.doesNotMatch(
buildContentSecurityPolicy('x', { supabaseUrl: 'javascript:alert(1)' }),
/javascript/,
'only an http(s) Supabase URL is added'
);

// The proxy: the same policy on the response and on the forwarded request.
const { NextRequest } = await import('next/server');
const { localeProxy } = await import('../../proxy');
const res = localeProxy(
new NextRequest('https://rate.vn/smartphones', {
headers: { host: 'rate.vn', 'x-forwarded-proto': 'https' },
})
);
const sent = res.headers.get(CSP_HEADER);
assert.ok(sent, 'pages carry the report-only policy');
assert.match(
sent,
/script-src 'self' 'nonce-[A-Za-z0-9+/=]+' 'strict-dynamic'/
);
assert.equal(
res.headers.get(`x-middleware-request-${CSP_HEADER.toLowerCase()}`),
sent,
'Next.js reads the same nonce from the request'
);
assert.equal(
res.headers.get('content-security-policy'),
null,
'nothing is enforced yet'
);
const res2 = localeProxy(
new NextRequest('https://rate.vn/smartphones', {
headers: { host: 'rate.vn', 'x-forwarded-proto': 'https' },
})
);
assert.notEqual(res2.headers.get(CSP_HEADER), sent, 'one nonce per request');

// The report endpoint logs a report and answers 204 whatever it gets.
const { POST } = await import('../../app/api/csp-report/route');
const lines: string[] = [];
// The logger writes warnings to stderr.
const original = console.error;
console.error = (...args: unknown[]) => {
lines.push(args.map(String).join(' '));
};
try {
const ok = await POST(
new Request('https://rate.vn/api/csp-report', {
method: 'POST',
headers: { 'content-type': 'application/csp-report' },
body: JSON.stringify({
'csp-report': {
'document-uri': 'https://rate.vn/smartphones?email=a@b.c',
'effective-directive': 'script-src-elem',
'blocked-uri': 'inline',
'line-number': 3,
},
}),
})
);
assert.equal(ok.status, 204);
const bad = await POST(
new Request('https://rate.vn/api/csp-report', {
method: 'POST',
body: 'not json',
})
);
assert.equal(bad.status, 204);
const before = lines.length;
// The same violation again is not logged twice.
await POST(
new Request('https://rate.vn/api/csp-report', {
method: 'POST',
body: JSON.stringify({
'csp-report': {
'document-uri': 'https://rate.vn/smartphones',
'effective-directive': 'script-src-elem',
'blocked-uri': 'inline',
},
}),
})
);
// A report without a directive, or for another site's page, is not logged.
for (const report of [
{},
{
'document-uri': 'https://evil.example/x',
'effective-directive': 'script-src-elem',
},
]) {
await POST(
new Request('https://rate.vn/api/csp-report', {
method: 'POST',
body: JSON.stringify({ 'csp-report': report }),
})
);
}
// A streamed body with no Content-Length stops being read past 8 KB.
let pulled = 0;
const big = new ReadableStream<Uint8Array>({
pull(controller) {
pulled += 1024;
if (pulled > 256 * 1024) controller.close();
else controller.enqueue(new Uint8Array(1024).fill(32));
},
});
const huge = await POST(
new Request('https://rate.vn/api/csp-report', {
method: 'POST',
body: big,
duplex: 'half',
} as RequestInit)
);
assert.equal(huge.status, 204);
assert.ok(pulled <= 16 * 1024, `read ${pulled} bytes of an oversized body`);
assert.equal(lines.length, before, 'no further log line');
} finally {
console.error = original;
}
const logged = lines.join('\n');
assert.match(logged, /csp violation/);
assert.match(logged, /script-src-elem/);
assert.match(logged, /https:\/\/rate\.vn\/smartphones/);
assert.doesNotMatch(logged, /email=/, 'no query string is logged');

console.warn(' ✅ report-only CSP with a per-request nonce (#1311)');
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
Loading
Loading