Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/e2e-db-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
- main
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand Down
48 changes: 45 additions & 3 deletions public/app/adapters/LinkValidationAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ export default class LinkValidationAdapter {

/**
* Remove invalid/non-validatable links
* Filters out: empty, anchors (#), javascript:, data: URLs
* Filters out: empty, anchors (#), and dangerous script schemes
* (javascript:, vbscript:, data:).
* @param {Array<{url: string, count: number}>} links
* @returns {Array<{url: string, count: number}>}
* @private
Expand All @@ -145,12 +146,53 @@ export default class LinkValidationAdapter {
return links.filter((link) => {
if (!link.url || link.url.trim() === '') return false;
if (link.url.startsWith('#')) return false;
if (link.url.startsWith('javascript:')) return false;
if (link.url.startsWith('data:')) return false;
if (this._hasDangerousScheme(link.url)) return false;
return true;
});
}

/**
* Detect whether a URL uses a dangerous script-bearing scheme
* (javascript:, vbscript:, data:).
*
* Normalizes the URL before matching so the check cannot be bypassed
* with mixed case, leading/embedded whitespace and control characters,
* or HTML character entities (e.g. "&#106;avascript:").
*
* @param {string} url
* @returns {boolean} true when the URL resolves to a dangerous scheme
* @private
*/
_hasDangerousScheme(url) {
if (typeof url !== 'string') return false;

// Safely convert a numeric code point, returning '' for invalid or
// out-of-range values so String.fromCodePoint cannot throw a
// RangeError (e.g. on "&#x110000;" or "&#9999999999;"). Dropping such
// junk only makes scheme detection stricter, never weaker.
const safeFromCodePoint = (cp) =>
Number.isInteger(cp) && cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : '';

// Decode common HTML entities (named and numeric) so encoded
// schemes such as "java&#115;cript:" are caught.
let normalized = url.replace(/&#x([0-9a-f]+);?/gi, (_m, hex) =>
safeFromCodePoint(Number.parseInt(hex, 16)),
);
normalized = normalized.replace(/&#(\d+);?/g, (_m, dec) =>
safeFromCodePoint(Number.parseInt(dec, 10)),
);
const namedEntities = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", colon: ':', tab: '\t', newline: '\n' };
normalized = normalized.replace(/&(amp|lt|gt|quot|apos|colon|tab|newline);/gi, (_m, name) => namedEntities[name.toLowerCase()] ?? _m);

// Strip whitespace and control characters (incl. NUL and DEL) that
// browsers ignore when resolving the scheme, then lowercase for
// comparison. \x00-\x20 covers C0 controls + space; \x7f is DEL.
// eslint-disable-next-line no-control-regex
const stripped = normalized.replace(/[\x00-\x20\x7f]+/g, '').toLowerCase();

return stripped.startsWith('javascript:') || stripped.startsWith('vbscript:') || stripped.startsWith('data:');
}

/**
* Deduplicate links, keeping the one with highest count
* @param {Array<{url: string, count: number}>} links
Expand Down
67 changes: 67 additions & 0 deletions public/app/adapters/LinkValidationAdapter.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,73 @@ describe('LinkValidationAdapter', () => {
expect(result.length).toBe(1);
expect(result[0].url).toBe('https://valid.com');
});

it('should reject vbscript:, mixed-case, whitespace-prefixed and entity-encoded script schemes', () => {
const dangerous = [
{ url: 'vbscript:msgbox(1)', count: 1 },
{ url: 'VBScript:MsgBox(1)', count: 1 },
{ url: 'JavaScript:alert(1)', count: 1 },
{ url: ' javascript:alert(1)', count: 1 },
{ url: '\t\njavascript:alert(1)', count: 1 },
{ url: 'java\x00script:alert(1)', count: 1 },
{ url: 'DATA:text/html,<script>alert(1)</script>', count: 1 },
{ url: '&#106;avascript:alert(1)', count: 1 },
{ url: 'java&#115;cript:alert(1)', count: 1 },
];
const result = adapter._removeInvalidLinks(dangerous);

expect(result).toEqual([]);
});

it('should preserve legitimate safe links', () => {
const safe = [
{ url: 'http://example.com', count: 1 },
{ url: 'https://example.com/page', count: 1 },
{ url: '//cdn.example.com/file.js', count: 1 },
{ url: 'images/photo.jpg', count: 1 },
{ url: 'mailto:user@example.com', count: 1 },
{ url: 'tel:+1234567890', count: 1 },
];
const result = adapter._removeInvalidLinks(safe);

expect(result.map((l) => l.url)).toEqual([
'http://example.com',
'https://example.com/page',
'//cdn.example.com/file.js',
'images/photo.jpg',
'mailto:user@example.com',
'tel:+1234567890',
]);
});
});

describe('_hasDangerousScheme', () => {
it('should detect dangerous schemes through obfuscation', () => {
expect(adapter._hasDangerousScheme('javascript:alert(1)')).toBe(true);
expect(adapter._hasDangerousScheme('vbscript:msgbox(1)')).toBe(true);
expect(adapter._hasDangerousScheme('data:text/html,abc')).toBe(true);
expect(adapter._hasDangerousScheme(' JAVASCRIPT:alert(1)')).toBe(true);
expect(adapter._hasDangerousScheme('&#106;avascript:alert(1)')).toBe(true);
expect(adapter._hasDangerousScheme('&#x6a;avascript:alert(1)')).toBe(true);
});

it('should not flag safe links and non-string input', () => {
expect(adapter._hasDangerousScheme('https://example.com')).toBe(false);
expect(adapter._hasDangerousScheme('images/photo.jpg')).toBe(false);
expect(adapter._hasDangerousScheme('mailto:user@example.com')).toBe(false);
expect(adapter._hasDangerousScheme(null)).toBe(false);
});

it('should not throw on out-of-range numeric entities', () => {
// String.fromCodePoint throws RangeError for code points > 0x10FFFF
// or negative; the guard must drop them instead of crashing.
expect(() => adapter._hasDangerousScheme('&#x110000;')).not.toThrow();
expect(() => adapter._hasDangerousScheme('&#9999999999;')).not.toThrow();
expect(adapter._hasDangerousScheme('&#x110000;')).toBe(false);
expect(adapter._hasDangerousScheme('&#9999999999;')).toBe(false);
// A real scheme padded with an out-of-range entity is still caught.
expect(adapter._hasDangerousScheme('&#x110000;javascript:alert(1)')).toBe(true);
});
});

describe('_deduplicateLinks', () => {
Expand Down
38 changes: 30 additions & 8 deletions public/app/common/LatexPreRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,18 +123,26 @@
let clean = latexWithHtml.replace(/<br\s*\/?>/gi, '\n');

// Remove any other HTML tags FIRST (before decoding entities)
// This prevents decoded < and > from being mistaken for tags
clean = clean.replace(/<[^>]+>/g, '');

// Decode common HTML entities AFTER removing tags
// This prevents decoded < and > from being mistaken for tags.
// Apply repeatedly until the string stops changing so that removing one
// tag cannot splice two halves into a new tag (e.g. "<<a>b>" -> "b>").
let prevClean;
do {
prevClean = clean;
clean = clean.replace(/<[^>]+>/g, '');
} while (clean !== prevClean);

// Decode common HTML entities AFTER removing tags.
// Decode &amp; LAST so that an already-escaped sequence like "&amp;lt;"
// (the literal text "&lt;") does not get double-decoded into "<".
clean = clean
.replace(/&nbsp;/gi, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
.replace(/&#x([a-fA-F0-9]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)));
.replace(/&#x([a-fA-F0-9]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)))
.replace(/&amp;/g, '&');

return clean;
}
Expand Down Expand Up @@ -711,7 +719,7 @@
function isNumberedEquation(latex) {
const envMatch = latex.match(/\\begin\{([^}*]+)\*?\}/);
if (!envMatch) return false;
const envName = envMatch[1].replace('*', ''); // Remove * for unnumbered variants
const envName = envMatch[1].replace(/\*/g, ''); // Remove * for unnumbered variants
return NUMBERED_EQUATION_ENVS.has(envName) && !envMatch[1].endsWith('*');
}

Expand Down Expand Up @@ -784,6 +792,14 @@
* @returns {Promise<{html: string, hasLatex: boolean, latexRendered: boolean, count: number}>}
*/
async function preRenderPerIdevice(html, preserved) {
// Security note (stored-xss): DOMParser.parseFromString builds an INERT
// document - scripts are NOT executed here, the document is only used to
// locate and re-serialize content. The ONLY new HTML this routine injects
// back via innerHTML is the MathJax library-rendered <svg>/<math> wrapper
// produced by createRenderedWrapperHtml(), whose data-latex attribute is
// escaped through escapeHtmlAttribute(). Every other byte is a faithful
// round-trip of the author's own already-HTML content (the trusted source
// of their own page), so this transformation introduces no new XSS sink.
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');

Expand Down Expand Up @@ -925,7 +941,13 @@
return await preRenderPerIdevice(safeHtml, preserved);
}

// Parse HTML into DOM (simple mode without iDevice scoping)
// Parse HTML into DOM (simple mode without iDevice scoping).
// Security note (stored-xss): identical reasoning to preRenderPerIdevice -
// the parsed document is INERT (no script execution), only used to find
// and re-serialize content. The single new HTML payload injected via
// innerHTML is the MathJax library-rendered <svg>/<math> wrapper with an
// escaped data-latex attribute; the remaining output is a faithful
// round-trip of the author's own already-HTML content.
const parser = new DOMParser();
const doc = parser.parseFromString(safeHtml, 'text/html');

Expand Down
72 changes: 72 additions & 0 deletions public/app/common/LatexPreRenderer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,44 @@ describe('LatexPreRenderer', () => {
expect(result).toContain('\\begin{aligned}');
expect(result).toContain('x &= 1');
});

// Security: incomplete-multi-character-sanitization (CodeQL).
// A single-pass tag strip can be defeated because removing one match
// can splice two halves into a NEW tag. The strip must run to a fixed
// point so no <script (case-insensitive) survives.
test('strips nested/obfuscated tags to a fixed point (no <script remains)', () => {
const input = 'a<scr<script>ipt>alert(1)</script>b';
const result = LatexPreRenderer._cleanLatexFromHtml(input);

// The security property: removing one tag must not splice halves into
// a surviving "<tag" sequence. No opening "<" of any tag may remain.
expect(result.toLowerCase()).not.toContain('<script');
expect(result).not.toContain('<');
});

test('strips doubly-nested broken tags without leaving an opening tag', () => {
const input = '<<a>b>x = 1<<img>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 &amp; must happen LAST so
// an already-escaped sequence like "&amp;lt;" (the literal text "&lt;")
// is not double-decoded into "<".
test('does not double-decode &amp;lt; into <', () => {
expect(LatexPreRenderer._cleanLatexFromHtml('&amp;lt;')).toBe('&lt;');
});

test('does not double-decode &amp;amp; into &amp;', () => {
expect(LatexPreRenderer._cleanLatexFromHtml('&amp;amp;')).toBe('&amp;');
});

test('still decodes a single &amp; to & (legitimate input preserved)', () => {
expect(LatexPreRenderer._cleanLatexFromHtml('a &amp;&amp; b')).toBe('a && b');
});
});

describe('preRender', () => {
Expand Down Expand Up @@ -421,6 +459,40 @@ describe('LatexPreRenderer', () => {
expect(result.html).not.toContain('data-latex="\\[<br>');
});

// 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 = '<p>\\(x" onmouseover="alert(1)\\)</p>';
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('&quot; onmouseover=&quot;alert(1)');
expect(dataLatexMatch[1]).not.toContain('"');
});

test('does not introduce a new live <script> when rendering math (round-trip is inert)', async () => {
// Author text that merely *contains* an angle bracket near math; the
// serialized output must only add the escaped library-rendered wrapper,
// not synthesize an executable script element from the LaTeX payload.
const html = '<p>\\(a < b\\) end</p>';
const result = await LatexPreRenderer.preRender(html);

expect(result.latexRendered).toBe(true);
expect(result.html).toContain('exe-math-rendered');
// No script element fabricated from the math payload.
expect(result.html.toLowerCase()).not.toContain('<script');
});

test('does NOT process LaTeX inside HTML attributes', async () => {
// LaTeX in title attribute should NOT be processed
const html = '<p><a href="#" title="Se escribe: \\( \\LaTeX \\)">\\(\\LaTeX\\)</a></p>';
Expand Down
58 changes: 46 additions & 12 deletions public/app/common/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -1806,15 +1806,27 @@ var $exeDevices = {


media: {
isMediatecaHost: function (hostname) {
return hostname === 'mediateca.educa.madrid.org';
},

extractURLGD: function (urlmedia) {
let sUrl = urlmedia;

if (
typeof urlmedia !== 'undefined' &&
urlmedia.length > 0 &&
urlmedia.toLowerCase().startsWith('https://drive.google.com') &&
urlmedia.toLowerCase().includes('sharing')
) {
let isGoogleDriveShare = false;
if (typeof urlmedia !== 'undefined' && urlmedia.length > 0) {
try {
const parsed = new URL(urlmedia);
isGoogleDriveShare =
parsed.protocol === 'https:' &&
parsed.hostname.toLowerCase() === 'drive.google.com' &&
urlmedia.toLowerCase().includes('sharing');
} catch (e) {
isGoogleDriveShare = false;
}
}

if (isGoogleDriveShare) {
sUrl = sUrl.replace(
/https:\/\/drive\.google\.com\/file\/d\/(.*?)\/.*?\?usp=sharing/g,
'https://docs.google.com/uc?export=open&id=$1'
Expand All @@ -1833,8 +1845,19 @@ var $exeDevices = {
getURLVideoMediaTeca: function (url) {
if (!url) return false;

if (url.includes("https://mediateca.educa.madrid.org/video/")) {
const id = url.split("https://mediateca.educa.madrid.org/video/")[1].split("?")[0];
let parsed;
try {
parsed = new URL(url);
} catch (e) {
return false;
}

if (
parsed.protocol === 'https:' &&
this.isMediatecaHost(parsed.hostname) &&
parsed.pathname.startsWith('/video/')
) {
const id = parsed.pathname.slice('/video/'.length);
return `http://mediateca.educa.madrid.org/streaming.php?id=${id}`;
}

Expand All @@ -1844,11 +1867,22 @@ var $exeDevices = {
getURLAudioMediaTeca: function (url) {
if (!url) return false;

let parsed;
try {
parsed = new URL(url);
} catch (e) {
return false;
}

if (parsed.protocol !== 'https:' || !this.isMediatecaHost(parsed.hostname)) {
return false;
}

let id = '';
if (url.includes("https://mediateca.educa.madrid.org/audio/")) {
id = url.split("https://mediateca.educa.madrid.org/audio/")[1].split("?")[0];
} else if (url.includes("https://mediateca.educa.madrid.org/video/")) {
id = url.split("https://mediateca.educa.madrid.org/video/")[1].split("?")[0];
if (parsed.pathname.startsWith('/audio/')) {
id = parsed.pathname.slice('/audio/'.length);
} else if (parsed.pathname.startsWith('/video/')) {
id = parsed.pathname.slice('/video/'.length);
} else {
return false;
}
Expand Down
Loading
Loading