diff --git a/src/app/api/_lib/http.ts b/src/app/api/_lib/http.ts index 9ea3f55..453cc7a 100644 --- a/src/app/api/_lib/http.ts +++ b/src/app/api/_lib/http.ts @@ -22,9 +22,21 @@ export function notFound(error: string, detail?: string): NextResponse 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 { - console.error(`[api:${scope}]`, cause); + console.error(`[api:${logLine(scope)}] ${logLine(cause)}`); + if (cause instanceof Error && cause.stack !== undefined) console.error(logLine(cause.stack)); return apiError(500, "Errore interno del server"); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e2bca7a..2a29119 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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"; @@ -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=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=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){}})();`; diff --git a/src/components/map/LineDetailView.tsx b/src/components/map/LineDetailView.tsx index db27491..57cad72 100644 --- a/src/components/map/LineDetailView.tsx +++ b/src/components/map/LineDetailView.tsx @@ -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)); } diff --git a/src/lib/inline-script.ts b/src/lib/inline-script.ts new file mode 100644 index 0000000..a860297 --- /dev/null +++ b/src/lib/inline-script.ts @@ -0,0 +1,12 @@ +/** + * JSON.stringify for values interpolated into an inline "); + assert.ok(!out.includes(""); +}); + +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, { 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"); +});