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
14 changes: 13 additions & 1 deletion src/app/api/_lib/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,21 @@ export function notFound(error: string, detail?: string): NextResponse<ApiError>
return apiError(404, error, detail);
}

/** One log line, whatever the message carries: no newline from a request can forge a second entry. */
export function logLine(value: unknown): string {
const text = value instanceof Error ? `${value.name}: ${value.message}` : String(value);
// Newlines dropped outright, in the one form CodeQL recognises as a sanitizer.
return text
.replace(/\n/g, "")
.replace(/\r/g, "")
.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, " ")
.slice(0, 2000);
}

/** Logs the real cause server-side and returns an opaque 500 to the client. */
export function serverError(scope: string, cause: unknown): NextResponse<ApiError> {
console.error(`[api:${scope}]`, cause);
console.error(`[api:${logLine(scope)}] ${logLine(cause)}`);
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
if (cause instanceof Error && cause.stack !== undefined) console.error(logLine(cause.stack));
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
return apiError(500, "Errore interno del server");
}

Expand Down
11 changes: 6 additions & 5 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import SkipLink from "@/components/SkipLink";
import TopBar from "@/components/TopBar";
import { LocaleProvider } from "@/lib/i18n";
import { DEFAULT_LOCALE, LOCALE_ALIASES, LOCALES, RTL_LOCALES } from "@/lib/i18n/locale";
import { jsLiteral } from "@/lib/inline-script";
import { STORAGE_KEYS } from "@/lib/types";
import "./globals.css";

Expand Down Expand Up @@ -48,15 +49,15 @@ export const viewport: Viewport = {
* hydration. Getting `dir` right here matters: an Arabic reader would otherwise
* see the whole layout jump sides once React mounts.
*/
const BOOTSTRAP = `(function(){try{var raw=localStorage.getItem(${JSON.stringify(
const BOOTSTRAP = `(function(){try{var raw=localStorage.getItem(${jsLiteral(
STORAGE_KEYS.settings,
)});var root=document.documentElement;var parsed=raw?JSON.parse(raw):null;var s=parsed&&typeof parsed==="object"?parsed:{};var theme=s.theme;if(theme==="dark"||theme==="light"){root.setAttribute("data-theme",theme);}else{root.removeAttribute("data-theme");}var known=${JSON.stringify(
)});var root=document.documentElement;var parsed=raw?JSON.parse(raw):null;var s=parsed&&typeof parsed==="object"?parsed:{};var theme=s.theme;if(theme==="dark"||theme==="light"){root.setAttribute("data-theme",theme);}else{root.removeAttribute("data-theme");}var known=${jsLiteral(
LOCALES,
)};var alias=${JSON.stringify(
)};var alias=${jsLiteral(
LOCALE_ALIASES,
)};var lang=s.language;if(known.indexOf(lang)<0){lang=null;var tags=(navigator.languages||[navigator.language||""]);for(var i=0;i<tags.length&&!lang;i++){var base=String(tags[i]).toLowerCase().split(/[-_]/)[0];if(known.indexOf(base)>=0)lang=base;else if(Object.prototype.hasOwnProperty.call(alias,base))lang=alias[base];}}lang=lang||${JSON.stringify(
)};var lang=s.language;if(known.indexOf(lang)<0){lang=null;var tags=(navigator.languages||[navigator.language||""]);for(var i=0;i<tags.length&&!lang;i++){var base=String(tags[i]).toLowerCase().split(/[-_]/)[0];if(known.indexOf(base)>=0)lang=base;else if(Object.prototype.hasOwnProperty.call(alias,base))lang=alias[base];}}lang=lang||${jsLiteral(
DEFAULT_LOCALE,
)};root.lang=lang;root.dir=${JSON.stringify(
)};root.lang=lang;root.dir=${jsLiteral(
RTL_LOCALES,
)}.indexOf(lang)>=0?"rtl":"ltr";}catch(e){}})();`;

Expand Down
3 changes: 1 addition & 2 deletions src/components/map/LineDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,7 @@ function safeDecode(encoded: string | null): Array<[number, number]> | null {
return clean.length > 1 ? clean : null;
}

function ageLabel(feedTimestamp: number | null, fetchedAt: number | null, t: Dictionary): string {
if (fetchedAt === null) return "";
function ageLabel(feedTimestamp: number | null, fetchedAt: number, t: Dictionary): string {
if (feedTimestamp !== null && feedTimestamp > 0) {
return t.line.dataAt(formatClock(feedTimestamp));
}
Expand Down
12 changes: 12 additions & 0 deletions src/lib/inline-script.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* JSON.stringify for values interpolated into an inline <script>. "<" and the
* two Unicode line separators are the characters that can end the tag or the
* statement early, so they go out as escapes; the result is still valid JSON
* and valid JavaScript.
*/
export function jsLiteral(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, "\\u003c")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
57 changes: 57 additions & 0 deletions tests/inline-script.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Values interpolated into the pre-paint <script> in layout.tsx. The parser
* closes a script element at the first "</script" it sees, before JavaScript
* ever runs, so the serialiser has to make that sequence impossible.
*/

import { test } from "node:test";
import assert from "node:assert/strict";

import { jsLiteral } from "@/lib/inline-script";
import { logLine } from "@/app/api/_lib/http";

function evaluate(source: string): unknown {
return new Function(`return ${source};`)();
}

test("a closing script tag inside a string cannot end the element", () => {
const out = jsLiteral("</script><script>alert(1)</script>");
assert.ok(!out.includes("</script"), out);
assert.ok(!out.includes("<"), out);
assert.equal(evaluate(out), "</script><script>alert(1)</script>");
});

test("the Unicode line separators are escaped, not emitted raw", () => {
const raw = "a\u2028b\u2029c";
const out = jsLiteral(raw);
assert.ok(!out.includes("\u2028") && !out.includes("\u2029"), out);
assert.equal(evaluate(out), raw);
});

test("the values the layout actually serialises round-trip unchanged", () => {
// The alias map is null-prototype in the source; the client gets a plain
// object, which is fine because the bootstrap reads it with hasOwnProperty.
const aliases = Object.assign(Object.create(null) as Record<string, string>, { fil: "tl", in: "id" });
const samples: Array<[unknown, unknown]> = [
["probus.settings.v1", "probus.settings.v1"],
[["it", "en", "ar"], ["it", "en", "ar"]],
[aliases, { fil: "tl", in: "id" }],
[null, null],
[42, 42],
];
for (const [value, expected] of samples) {
assert.deepEqual(evaluate(jsLiteral(value)), expected);
}
});

test("a log line cannot carry a newline from a request", () => {
const forged = "stop 123\n[api:auth] admin login ok\r\n";
const out = logLine(new Error(forged));
assert.ok(!/[\r\n]/.test(out), out);
assert.ok(out.startsWith("Error: stop 123"), out);
});

test("a log line is capped so a huge message cannot flood the log", () => {
assert.ok(logLine("x".repeat(10_000)).length <= 2000);
assert.equal(logLine(undefined), "undefined");
});
Loading