Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
951f11d
fix(security): resolve 82 CodeQL code-scanning alerts
erseco Jun 5, 2026
6729d13
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Jun 18, 2026
b53a50f
fix(security): address review findings on the CodeQL hardening PR
erseco Jun 18, 2026
1b4be64
fix(security): pin mindmaps CDN scripts (js/functionality-from-untrus…
erseco Jun 18, 2026
34c3352
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Jul 2, 2026
9f55fd0
Merge branch 'main' into hotfix/fix-codeql-warnings
github-actions[bot] Jul 15, 2026
31f5582
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Aug 3, 2026
b8938cf
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Aug 4, 2026
abff3d4
ci: re-trigger pull_request checks after main merge
erseco Aug 4, 2026
b88d2f3
fix(security): drop stray parenthesis in BaseLegacyHandler entity decode
erseco Aug 4, 2026
dcef0ef
ci: re-run checks after cancelled workflow runs
erseco Aug 4, 2026
4d6edb3
ci: re-run checks
erseco Aug 4, 2026
2592bd4
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Aug 11, 2026
19c7aea
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Aug 16, 2026
b2b18c6
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Aug 17, 2026
250bcb6
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Aug 21, 2026
9cc6e54
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Aug 22, 2026
bd67076
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Aug 23, 2026
4bc7376
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Aug 25, 2026
4655d67
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Aug 26, 2026
fdf6e58
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Aug 28, 2026
3cb0178
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Aug 30, 2026
47c5688
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Aug 31, 2026
908d79f
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Aug 31, 2026
a6e5490
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Sep 1, 2026
f1f4d7d
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Sep 2, 2026
649ee5f
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Sep 3, 2026
dca39a0
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Sep 3, 2026
5b0a0fe
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Sep 3, 2026
6fd46e4
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Sep 5, 2026
676b357
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Sep 6, 2026
e1cfdc6
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Sep 7, 2026
83db2b3
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Sep 8, 2026
07b98de
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Sep 9, 2026
872487f
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Sep 13, 2026
e33955d
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Sep 14, 2026
297e5dd
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Sep 14, 2026
691f28f
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Sep 15, 2026
e440433
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Sep 15, 2026
fce28be
Merge branch 'main' into hotfix/fix-codeql-warnings
erseco Sep 16, 2026
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 @@ -23,6 +23,9 @@ on:
- doc/deploy/docker-compose.*.yml
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 @@ -141,7 +141,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 @@ -150,12 +151,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 @@ -485,6 +485,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 @@ -2563,15 +2563,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 @@ -2590,8 +2602,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 @@ -2601,11 +2624,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