fix(i18n): ship i18n data as a JSON block so CSP cannot block it
window.__wrnI18n was undefined in development: the payload shipped as an executable inline script, and a document's CSP nonce is fixed at load, so any such script arriving from a later response is blocked. Client translations and language switching silently had no data. The payload is now a type="application/json" block, which the browser never executes and script-src therefore never applies to. The i18n runtime, CSR navigation, and HMR all read the block instead of matching window.__wrnI18n= with a regex. Pages now render zero executable inline scripts, so an inline script-src violation is structurally impossible rather than merely unobserved. Zero framework JavaScript on island-free routes is unaffected: the block is inert data, and nothing loads to read it unless the page needs it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1674,6 +1674,7 @@
|
|||||||
"@wrnexus/i18n": {
|
"@wrnexus/i18n": {
|
||||||
".": [
|
".": [
|
||||||
"ExtractedTranslationKey",
|
"ExtractedTranslationKey",
|
||||||
|
"I18N_DATA_ATTRIBUTE",
|
||||||
"I18N_JS_HREF",
|
"I18N_JS_HREF",
|
||||||
"I18N_RUNTIME",
|
"I18N_RUNTIME",
|
||||||
"I18nConfig",
|
"I18nConfig",
|
||||||
@@ -1710,6 +1711,7 @@
|
|||||||
"plural",
|
"plural",
|
||||||
"pseudoLocalize",
|
"pseudoLocalize",
|
||||||
"renderI18nData",
|
"renderI18nData",
|
||||||
|
"renderI18nDataTag",
|
||||||
"resolveI18n",
|
"resolveI18n",
|
||||||
"resolveLang",
|
"resolveLang",
|
||||||
"translateHtml",
|
"translateHtml",
|
||||||
|
|||||||
@@ -368,15 +368,10 @@ export const NAV_RUNTIME = String.raw`
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncI18n(nextDocument) {
|
function syncI18n(nextDocument) {
|
||||||
var script = Array.prototype.find.call(
|
var script = nextDocument.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||||
nextDocument.querySelectorAll("script:not([src])"),
|
|
||||||
function (node) { return /^window\.__wrnI18n=/.test(String(node.textContent || "").trim()); },
|
|
||||||
);
|
|
||||||
if (!script) return;
|
if (!script) return;
|
||||||
var match = /^window\.__wrnI18n=([\s\S]*);\s*$/.exec(String(script.textContent || "").trim());
|
|
||||||
if (!match) return;
|
|
||||||
try {
|
try {
|
||||||
var incoming = JSON.parse(match[1]);
|
var incoming = JSON.parse(String(script.textContent || "{}"));
|
||||||
var current = window.__wrnI18n || {};
|
var current = window.__wrnI18n || {};
|
||||||
var translator = current.t;
|
var translator = current.t;
|
||||||
var setter = current.set;
|
var setter = current.set;
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ test("synchronizes and rebinds i18n data during client navigation", async () =>
|
|||||||
win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) };
|
win.__wrnLang = { bind: (root: unknown) => (boundRoot = root) };
|
||||||
nextHtml =
|
nextHtml =
|
||||||
`<html lang="mr"><body><div id="app"><p data-t="home.title">नवीन</p></div>` +
|
`<html lang="mr"><body><div id="app"><p data-t="home.title">नवीन</p></div>` +
|
||||||
`<script>window.__wrnI18n={"lang":"mr","messages":{"home":{"title":"नवीन"}},"fallbackMessages":{}};</script>` +
|
`<script type="application/json" data-wrn-i18n>{"lang":"mr","messages":{"home":{"title":"नवीन"}},"fallbackMessages":{}}</script>` +
|
||||||
`</body></html>`;
|
`</body></html>`;
|
||||||
|
|
||||||
win.document.getElementById("lnk").click();
|
win.document.getElementById("lnk").click();
|
||||||
@@ -180,7 +180,7 @@ test("preserves same-language translations when an incoming navigation catalog i
|
|||||||
};
|
};
|
||||||
nextHtml =
|
nextHtml =
|
||||||
`<html lang="en"><body><div id="app"><p data-t="navigation.home">navigation.home</p></div>` +
|
`<html lang="en"><body><div id="app"><p data-t="navigation.home">navigation.home</p></div>` +
|
||||||
`<script>window.__wrnI18n={"lang":"en","messages":{},"fallbackMessages":{}};</script>` +
|
`<script type="application/json" data-wrn-i18n>{"lang":"en","messages":{},"fallbackMessages":{}}</script>` +
|
||||||
`</body></html>`;
|
`</body></html>`;
|
||||||
|
|
||||||
win.document.getElementById("lnk").click();
|
win.document.getElementById("lnk").click();
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ import {
|
|||||||
type TenancyConfig,
|
type TenancyConfig,
|
||||||
} from "@wrnexus/styles";
|
} from "@wrnexus/styles";
|
||||||
import {
|
import {
|
||||||
renderI18nData,
|
renderI18nDataTag,
|
||||||
makeT,
|
makeT,
|
||||||
resolveLang,
|
resolveLang,
|
||||||
translateHtml,
|
translateHtml,
|
||||||
@@ -672,22 +672,16 @@ export const HMR_CLIENT_JS = `
|
|||||||
pendingSync = false;
|
pendingSync = false;
|
||||||
var doc = new DOMParser().parseFromString(html, "text/html");
|
var doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
|
||||||
var i18nScript = Array.prototype.find.call(
|
var i18nScript = doc.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||||
doc.querySelectorAll("script:not([src])"),
|
|
||||||
function (node) { return /^window[.]__wrnI18n=/.test(String(node.textContent || "").trim()); },
|
|
||||||
);
|
|
||||||
if (i18nScript) {
|
if (i18nScript) {
|
||||||
var i18nMatch = /^window[.]__wrnI18n=([^]*);\\s*$/.exec(String(i18nScript.textContent || "").trim());
|
try {
|
||||||
if (i18nMatch) {
|
var incomingI18n = JSON.parse(String(i18nScript.textContent || "{}"));
|
||||||
try {
|
var existingI18n = window.__wrnI18n || {};
|
||||||
var incomingI18n = JSON.parse(i18nMatch[1]);
|
incomingI18n.t = existingI18n.t;
|
||||||
var existingI18n = window.__wrnI18n || {};
|
incomingI18n.set = existingI18n.set;
|
||||||
incomingI18n.t = existingI18n.t;
|
window.__wrnI18n = incomingI18n;
|
||||||
incomingI18n.set = existingI18n.set;
|
} catch (error) {
|
||||||
window.__wrnI18n = incomingI18n;
|
console.error("[wrnexus] failed to synchronize i18n HMR data", error);
|
||||||
} catch (error) {
|
|
||||||
console.error("[wrnexus] failed to synchronize i18n HMR data", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1978,9 +1972,7 @@ export function createHandlers(deps: RuntimeDeps): Handlers {
|
|||||||
extraBody:
|
extraBody:
|
||||||
[
|
[
|
||||||
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
|
renderStoreHydration(storeContainer, (ctx.locals.cspNonce as string) ?? undefined),
|
||||||
deps.i18n
|
deps.i18n ? renderI18nDataTag(deps.i18n, language) : "",
|
||||||
? `<script${ctx.locals.cspNonce ? ` nonce="${String(ctx.locals.cspNonce)}"` : ""}>${renderI18nData(deps.i18n, language)}</script>`
|
|
||||||
: "",
|
|
||||||
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
|
hmr ? hmrClientTag((ctx.locals.cspNonce as string) ?? "") : "",
|
||||||
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
|
shouldEnableDevToolbar(mode, deps) ? DEV_TOOLBAR_SCRIPT : "",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -425,9 +425,20 @@ function safeJson(value: unknown): string {
|
|||||||
.replace(/\u2029/g, "\\u2029");
|
.replace(/\u2029/g, "\\u2029");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Attribute marking the JSON block that carries per-request i18n data. */
|
||||||
|
export const I18N_DATA_ATTRIBUTE = "data-wrn-i18n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The i18n payload, emitted as JSON rather than as an assignment.
|
||||||
|
*
|
||||||
|
* It ships inside a `type="application/json"` block, which the browser never
|
||||||
|
* executes, so `script-src` does not apply to it. As an inline executable
|
||||||
|
* script it was blocked whenever the surrounding document's CSP nonce came
|
||||||
|
* from a different response, leaving window.__wrnI18n undefined.
|
||||||
|
*/
|
||||||
export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
||||||
const active = i18n.langs.includes(lang) ? lang : i18n.default;
|
const active = i18n.langs.includes(lang) ? lang : i18n.default;
|
||||||
return `window.__wrnI18n=${safeJson({
|
return `${safeJson({
|
||||||
lang: active,
|
lang: active,
|
||||||
langs: i18n.langs,
|
langs: i18n.langs,
|
||||||
default: i18n.default,
|
default: i18n.default,
|
||||||
@@ -437,7 +448,12 @@ export function renderI18nData(i18n: ResolvedI18n, lang: string): string {
|
|||||||
directions: i18n.direction,
|
directions: i18n.direction,
|
||||||
labels: i18n.labels,
|
labels: i18n.labels,
|
||||||
cookie: i18n.cookie,
|
cookie: i18n.cookie,
|
||||||
})};`;
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full JSON block, including its script tag. */
|
||||||
|
export function renderI18nDataTag(i18n: ResolvedI18n, lang: string): string {
|
||||||
|
return `<script type="application/json" ${I18N_DATA_ATTRIBUTE}>${renderI18nData(i18n, lang)}</script>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const I18N_RUNTIME = String.raw`
|
export const I18N_RUNTIME = String.raw`
|
||||||
@@ -457,7 +473,23 @@ export const I18N_RUNTIME = String.raw`
|
|||||||
return params && Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : "{" + name + "}";
|
return params && Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : "{" + name + "}";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function state() { return window.__wrnI18n || {}; }
|
function readDataBlock() {
|
||||||
|
var node = document.querySelector('script[type="application/json"][data-wrn-i18n]');
|
||||||
|
if (!node) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(node.textContent || "{}");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[wrnexus] i18n data block was not valid JSON", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function state() {
|
||||||
|
if (!window.__wrnI18n) {
|
||||||
|
var data = readDataBlock();
|
||||||
|
if (data) window.__wrnI18n = data;
|
||||||
|
}
|
||||||
|
return window.__wrnI18n || {};
|
||||||
|
}
|
||||||
function t(key, params) {
|
function t(key, params) {
|
||||||
var current = state();
|
var current = state();
|
||||||
return interpolate(lookup(current.messages, key) || lookup(current.fallbackMessages, key) || key, params);
|
return interpolate(lookup(current.messages, key) || lookup(current.fallbackMessages, key) || key, params);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { I18N_RUNTIME, renderI18nData, renderI18nDataTag, resolveI18n } from "../src/index.ts";
|
||||||
|
|
||||||
|
const i18n = resolveI18n({ en: { hello: "Hello" }, es: { hello: "Hola" } }, { default: "en" });
|
||||||
|
|
||||||
|
test("the i18n payload is plain JSON, not an assignment", () => {
|
||||||
|
const data = renderI18nData(i18n, "en");
|
||||||
|
expect(() => JSON.parse(data)).not.toThrow();
|
||||||
|
expect(data).not.toContain("window.__wrnI18n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the data tag is a non-executable JSON block", () => {
|
||||||
|
// An executable inline script is subject to script-src and gets blocked
|
||||||
|
// whenever the document's CSP nonce came from a different response, which is
|
||||||
|
// what left window.__wrnI18n undefined. A JSON block is never executed.
|
||||||
|
const tag = renderI18nDataTag(i18n, "es");
|
||||||
|
expect(tag).toContain('type="application/json"');
|
||||||
|
expect(tag).toContain("data-wrn-i18n");
|
||||||
|
expect(tag).not.toContain("nonce=");
|
||||||
|
expect(tag).toContain("Hola");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the i18n runtime reads the data block instead of relying on an inline assignment", () => {
|
||||||
|
expect(I18N_RUNTIME).toContain('script[type="application/json"][data-wrn-i18n]');
|
||||||
|
});
|
||||||
@@ -145,7 +145,7 @@ check(
|
|||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
"i18n runtime data is injected into rendered documents",
|
"i18n runtime data is injected into rendered documents",
|
||||||
has("packages/dev-server/src/runtime.ts", "renderI18nData(deps.i18n, language)") &&
|
has("packages/dev-server/src/runtime.ts", "renderI18nDataTag(deps.i18n, language)") &&
|
||||||
has("packages/dev-server/src/runtime.ts", "deps.i18n.cookie.name"),
|
has("packages/dev-server/src/runtime.ts", "deps.i18n.cookie.name"),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
|
|||||||
Reference in New Issue
Block a user