b>x = 1<
src>';
+ const result = LatexPreRenderer._cleanLatexFromHtml(input);
+
+ // After fixed-point removal no opening "<" (start of a tag) may remain.
+ expect(result).not.toContain('<');
+ expect(result).toContain('x = 1');
+ });
+
+ // Security: double-escaping (CodeQL). Decoding & must happen LAST so
+ // an already-escaped sequence like "<" (the literal text "<")
+ // is not double-decoded into "<".
+ test('does not double-decode < into <', () => {
+ expect(LatexPreRenderer._cleanLatexFromHtml('<')).toBe('<');
+ });
+
+ test('does not double-decode & into &', () => {
+ expect(LatexPreRenderer._cleanLatexFromHtml('&')).toBe('&');
+ });
+
+ test('still decodes a single & to & (legitimate input preserved)', () => {
+ expect(LatexPreRenderer._cleanLatexFromHtml('a && b')).toBe('a && b');
+ });
});
describe('preRender', () => {
@@ -421,6 +459,40 @@ describe('LatexPreRenderer', () => {
expect(result.html).not.toContain('data-latex="\\[
');
});
+ // Security: stored-xss (CodeQL) on the DOMParser round-trip.
+ // The only NEW HTML preRender injects is the MathJax library-rendered
+ // wrapper, with its data-latex attribute escaped via escapeHtmlAttribute().
+ // LaTeX that contains a quote/angle-bracket payload must be neutralised in
+ // the attribute (escaped), never emitted as a live attribute/tag.
+ test('escapes quote/angle payloads inside the injected math wrapper attribute', async () => {
+ const html = '\\(x" onmouseover="alert(1)\\)
';
+ const result = await LatexPreRenderer.preRender(html);
+
+ expect(result.latexRendered).toBe(true);
+ // The injected wrapper is present and library-rendered.
+ expect(result.html).toContain('exe-math-rendered');
+ // The wrapper's own data-latex attribute (built by THIS file via
+ // escapeHtmlAttribute) must entity-escape the double quote so the
+ // payload cannot break out into a live onmouseover handler.
+ const dataLatexMatch = result.html.match(/data-latex="([^"]*)"/);
+ expect(dataLatexMatch).not.toBeNull();
+ expect(dataLatexMatch[1]).toContain('" onmouseover="alert(1)');
+ expect(dataLatexMatch[1]).not.toContain('"');
+ });
+
+ test('does not introduce a new live
-
-
+
+
+
+
diff --git a/public/app/core/EmbeddingBridge.js b/public/app/core/EmbeddingBridge.js
index 9fd84ba6dd..2bac3bb9df 100644
--- a/public/app/core/EmbeddingBridge.js
+++ b/public/app/core/EmbeddingBridge.js
@@ -92,14 +92,17 @@ export default class EmbeddingBridge {
const documentManager = bridge?.documentManager;
const navigation = documentManager?.getNavigation?.();
- window.parent.postMessage({
+ // Route through postToParent so this inherits the known-origin guard.
+ // DOCUMENT_LOADED carries project metadata (id, dirty state, page count)
+ // that must never be broadcast to '*': until the parent handshake has
+ // set a trusted parentOrigin, the message is dropped rather than leaked
+ // to every embedding origin (js/cross-window-information-leak).
+ this.postToParent({
type: 'DOCUMENT_LOADED',
projectId: bridge?.projectId || null,
isDirty: documentManager?.isDirty || false,
pageCount: navigation?.length || 0,
- }, this.parentOrigin || '*');
-
- getLogger().log('[EmbeddingBridge] Announced DOCUMENT_LOADED to parent');
+ });
}
/**
@@ -457,12 +460,9 @@ export default class EmbeddingBridge {
try {
window.parent.postMessage(message, this.parentOrigin);
} catch (e) {
- // If origin validation fails, try with '*' for same-origin iframes
- if (e.name === 'DataCloneError') {
- getLogger().error('[EmbeddingBridge] Cannot serialize message:', e);
- } else {
- window.parent.postMessage(message, '*');
- }
+ // Never broadcast to '*' on failure: it would leak the message to
+ // any origin. Log the error and drop the message instead.
+ getLogger().error('[EmbeddingBridge] Failed to post message to parent:', e);
}
}
}
diff --git a/public/app/core/EmbeddingBridge.test.js b/public/app/core/EmbeddingBridge.test.js
index acf997adc1..b5bee205ac 100644
--- a/public/app/core/EmbeddingBridge.test.js
+++ b/public/app/core/EmbeddingBridge.test.js
@@ -1036,24 +1036,32 @@ describe('EmbeddingBridge', () => {
);
});
- it('should fallback to * on non-DataCloneError', () => {
+ it('should never broadcast to * when posting to parentOrigin throws', () => {
bridge.parentOrigin = 'https://parent.com';
- window.parent.postMessage = vi.fn()
- .mockImplementationOnce(() => {
- throw new Error('Some other error');
- })
- .mockImplementationOnce(() => {});
+ const otherError = new Error('Some other error');
+ window.parent.postMessage = vi.fn(() => {
+ throw otherError;
+ });
bridge.postToParent({ type: 'TEST' });
- expect(window.parent.postMessage).toHaveBeenCalledTimes(2);
- expect(window.parent.postMessage).toHaveBeenLastCalledWith(
+ // Only the single targeted attempt is made; no '*' fallback.
+ expect(window.parent.postMessage).toHaveBeenCalledTimes(1);
+ expect(window.parent.postMessage).toHaveBeenCalledWith(
{ type: 'TEST' },
+ 'https://parent.com'
+ );
+ expect(window.parent.postMessage).not.toHaveBeenCalledWith(
+ expect.anything(),
'*'
);
+ expect(window.AppLogger.error).toHaveBeenCalledWith(
+ '[EmbeddingBridge] Failed to post message to parent:',
+ otherError
+ );
});
- it('should log error on DataCloneError', () => {
+ it('should log error on DataCloneError without broadcasting to *', () => {
bridge.parentOrigin = 'https://parent.com';
const dataCloneError = new Error('Cannot clone');
dataCloneError.name = 'DataCloneError';
@@ -1063,8 +1071,13 @@ describe('EmbeddingBridge', () => {
bridge.postToParent({ type: 'TEST' });
+ expect(window.parent.postMessage).toHaveBeenCalledTimes(1);
+ expect(window.parent.postMessage).not.toHaveBeenCalledWith(
+ expect.anything(),
+ '*'
+ );
expect(window.AppLogger.error).toHaveBeenCalledWith(
- '[EmbeddingBridge] Cannot serialize message:',
+ '[EmbeddingBridge] Failed to post message to parent:',
dataCloneError
);
});
@@ -1165,24 +1178,25 @@ describe('EmbeddingBridge', () => {
);
});
- it('should use * as target origin when parentOrigin is not set', async () => {
+ it('does not broadcast DOCUMENT_LOADED to "*" when parentOrigin is not set', async () => {
let resolveDocReady;
window.eXeLearning.documentReady = new Promise((resolve) => {
resolveDocReady = resolve;
});
bridge.init();
- // parentOrigin is null by default
+ // parentOrigin is null by default (no parent handshake received yet)
resolveDocReady();
await new Promise(resolve => setTimeout(resolve, 0));
- // Should have posted DOCUMENT_LOADED with '*' origin
+ // The project metadata (id, dirty state, page count) must NOT leak to
+ // every embedding origin: with no known parent origin the announcement
+ // is dropped rather than sent to '*' (js/cross-window-information-leak).
const docLoadedCall = window.parent.postMessage.mock.calls.find(
call => call[0]?.type === 'DOCUMENT_LOADED'
);
- expect(docLoadedCall).toBeTruthy();
- expect(docLoadedCall[1]).toBe('*');
+ expect(docLoadedCall).toBeUndefined();
});
it('should handle missing yjsBridge gracefully in DOCUMENT_LOADED', async () => {
@@ -1194,6 +1208,9 @@ describe('EmbeddingBridge', () => {
mockApp.project._yjsBridge = null;
bridge.init();
+ // A trusted parent origin is known, so the announcement is sent and
+ // we can assert it degrades gracefully (null/zero) without a bridge.
+ bridge.parentOrigin = 'https://parent.com';
resolveDocReady();
await new Promise(resolve => setTimeout(resolve, 0));
@@ -1204,6 +1221,7 @@ describe('EmbeddingBridge', () => {
expect(docLoadedCall[0].projectId).toBeNull();
expect(docLoadedCall[0].isDirty).toBe(false);
expect(docLoadedCall[0].pageCount).toBe(0);
+ expect(docLoadedCall[1]).toBe('https://parent.com');
});
it('should not send DOCUMENT_LOADED when documentReady is not available', () => {
diff --git a/public/app/locate/locale.js b/public/app/locate/locale.js
index a0cb0c547a..4faee44fc9 100644
--- a/public/app/locate/locale.js
+++ b/public/app/locate/locale.js
@@ -1,3 +1,18 @@
+/**
+ * Escape a string for use as a legacy escaped catalogue key.
+ *
+ * Backslashes are escaped FIRST and quotes second so the sanitization is
+ * complete: an input such as `\"` becomes `\\\"` instead of the ambiguous
+ * `\\"`. Inputs without backslashes (the common case, including every real
+ * translation source string) are unaffected, preserving existing lookups.
+ *
+ * @param {string} string
+ * @returns {string}
+ */
+function escapeCatalogueKey(string) {
+ return string.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+}
+
export default class Locale {
constructor(app) {
this.app = app;
@@ -67,7 +82,7 @@ export default class Locale {
const catalogue = this.strings?.translations;
if (catalogue) {
// Try exact key first (server returns unescaped keys)
- const key = string in catalogue ? string : string.replace(/"/g, '\\"');
+ const key = string in catalogue ? string : escapeCatalogueKey(string);
if (key in catalogue) {
let res = catalogue[key].replace(/\\"/g, '"').replace(/\\\//g, '/');
if (res.startsWith('~')) res = res.substring(1);
@@ -84,7 +99,7 @@ export default class Locale {
const catalogue = this.c_strings?.translations;
if (catalogue) {
// Try exact key first (server returns unescaped keys)
- const key = string in catalogue ? string : string.replace(/"/g, '\\"');
+ const key = string in catalogue ? string : escapeCatalogueKey(string);
if (key in catalogue) {
let res = catalogue[key].replace(/\\"/g, '"').replace(/\\\//g, '/');
if (res.startsWith('~')) res = res.substring(1);
@@ -152,7 +167,7 @@ export default class Locale {
*/
getTranslation(string, lang, idevice) {
if (typeof string != 'string') return '';
- string = string ? string.replace(/"/g, '\\"') : '';
+ string = string ? escapeCatalogueKey(string) : '';
lang = lang ? lang : this.lang;
// Idevice po translation
if (idevice) {
diff --git a/public/app/locate/locale.test.js b/public/app/locate/locale.test.js
index d4e3669c66..3485296986 100644
--- a/public/app/locate/locale.test.js
+++ b/public/app/locate/locale.test.js
@@ -235,4 +235,65 @@ describe('Locale translations', () => {
expect(locale.getGUITranslation('text "quoted"')).toBe('text "quoted"');
});
});
+
+ describe('catalogue key escaping (incomplete-sanitization fix)', () => {
+ // Real XLF keys are stored verbatim (unescaped). The first lookup
+ // branch handles those, so quoted source strings keep resolving exactly as
+ // before. These cases lock in that legitimate-input behaviour.
+ it('getGUITranslation still resolves a real quoted source key', () => {
+ locale.strings = { translations: { 'Click the "Reply" button.': '~Pulsa el botón "Responder".' } };
+ expect(locale.getGUITranslation('Click the "Reply" button.')).toBe('Pulsa el botón "Responder".');
+ });
+
+ it('getContentTranslation still resolves a real quoted source key', () => {
+ locale.c_strings = { translations: { 'Open "{name}".': 'Abrir "{name}".' } };
+ expect(locale.getContentTranslation('Open "{name}".')).toBe('Abrir "{name}".');
+ });
+
+ // Security property (incomplete-sanitization): backslashes in the input
+ // must be escaped BEFORE quotes, so a literal backslash in the lookup key
+ // is doubled. The naive `replace(/"/g, '\\"')` left backslashes untouched,
+ // letting `\"` collapse ambiguously. Inputs WITHOUT backslashes (every real
+ // translation source string) escape identically, preserving lookups.
+
+ it('getGUITranslation doubles backslashes when building the fallback key', () => {
+ // Plain quote (no backslash) must still escape to `\"` and resolve the
+ // legacy escaped key exactly as before — behaviour preserved.
+ // Key `\\"` in JS source = backslash + quote.
+ locale.strings = { translations: { '\\"': 'plain-quote-value' } };
+ expect(locale.getGUITranslation('"')).toBe('plain-quote-value');
+
+ // Backslash-bearing input `\y` is NOT a direct key, so it goes through the
+ // escaping fallback. Backslash-first escaping builds key `\\y` (doubled
+ // backslash + y). The catalogue exposes only that correctly-escaped key
+ // plus the naive single-backslash form; the fix must resolve the former.
+ locale.strings = {
+ translations: { '\\\\y': 'correct', z: 'unused' },
+ };
+ expect(locale.getGUITranslation('\\y')).toBe('correct');
+ });
+
+ it('getContentTranslation doubles backslashes when building the fallback key', () => {
+ locale.c_strings = { translations: { '\\"': 'plain-quote-value' } };
+ expect(locale.getContentTranslation('"')).toBe('plain-quote-value');
+
+ locale.c_strings = {
+ translations: { '\\\\y': 'correct', z: 'unused' },
+ };
+ expect(locale.getContentTranslation('\\y')).toBe('correct');
+ });
+
+ it('getTranslation doubles backslashes when building the lookup key', () => {
+ // Plain quote still escapes to `\"` and resolves the legacy escaped key.
+ locale.strings = { translations: { '\\"': 'plain-quote-value' } };
+ expect(locale.getTranslation('"')).toBe('plain-quote-value');
+
+ // getTranslation escapes the input unconditionally; with backslash-first
+ // escaping `\y` becomes `\\y` (doubled), matching the correctly-escaped key.
+ locale.strings = {
+ translations: { '\\\\y': 'correct', z: 'unused' },
+ };
+ expect(locale.getTranslation('\\y')).toBe('correct');
+ });
+ });
});
diff --git a/public/app/workarea/interface/elements/previewPanel.js b/public/app/workarea/interface/elements/previewPanel.js
index 9f9f137bfa..acb88e0d51 100644
--- a/public/app/workarea/interface/elements/previewPanel.js
+++ b/public/app/workarea/interface/elements/previewPanel.js
@@ -1156,7 +1156,7 @@ export default class PreviewPanelManager {
// Inline JS: (note: space inside tag is common)
html = html.replace(
- /