Files
port-ui/app/cypress/e2e/injection.spec.js

264 lines
8.6 KiB
JavaScript
Raw Normal View History

fix(modal): stop untrusted content reaching innerHTML and the iframe Every one of these paths checked a string that the browser reinterprets afterwards. isSafeUrl now hangs the value on an <a> and reads back probe.protocol, so the check sees what the browser will see: a pre-parse test reads "&#106;avascript:" as a relative path and passes it, and the HTML parser then decodes it to "javascript:". marked passes raw HTML through and emits hrefs unescaped. renderMarkdown escapes the angle brackets before parsing, parses into an inert DOMParser document where no script runs and no image loads, and drops anchors and images whose scheme is not http, https or mailto. Blockquotes and <autolinks> stop working as a result; neither appears in the configuration. modalTitle and the alternatives list interpolated subitem.name and icon.class into innerHTML. Both are built as nodes now. name is a translatable key, so it arrives from the machine-written catalogues. The link kept its click handler and its class across popups, because one anchor serves all of them: a later, unrelated click opened whatever an earlier popup pointed at, and addEventListener stacked one handler per open. Both are reset per popup and the handler is assigned, not added. openIframe guards its own argument. Removing the href alone left the handler passing the raw URL on, and ?iframe= in the query string reaches the same sink with no configuration involved at all. Verified in headless Chromium: decimal and hex character references, &Tab;- and &NewLine;-split schemes, reference-style links, raw HTML as a link's text, and the two name sinks all executed before these changes. injection.spec.js keeps all fifteen payloads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:04:25 +02:00
// cypress/e2e/injection.spec.js
describe('Untrusted content in the modal', () => {
const base = {
name: 'Test Item',
identifier: 'ABC123',
icon: { class: 'fa fa-test' },
};
beforeEach(() => {
cy.visit('/');
cy.window().then(win => {
cy.stub(win.navigator.clipboard, 'writeText').resolves();
cy.stub(win, 'alert');
});
});
function open(item = {}) {
cy.window().invoke('openDynamicPopup', { ...base, ...item });
}
describe('markdown rendered into innerHTML', () => {
it('strips a plain script URL', () => {
open({
warning: '[click me](javascript:window.__xss = true)',
info: '![x](data:text/html;base64,PHNjcmlwdD4=)',
});
cy.get('#dynamicModalWarningText').find('a').should('not.exist');
cy.get('#dynamicModalWarningText').should('contain.text', 'click me');
cy.get('#dynamicModalInfoText').find('img').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('strips a script URL hidden behind character references', () => {
open({
warning:
'[a](&#106;avascript:window.__xss=1) [b](&#x6A;avascript:window.__xss=1)',
info: '[c](java&Tab;script:window.__xss=1) [d](java&NewLine;script:window.__xss=1)',
});
cy.get('#dynamicModalWarningText').find('a').should('not.exist');
cy.get('#dynamicModalInfoText').find('a').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('strips a script URL written as a reference-style link', () => {
open({
warning: '[click me][ref]\n\n[ref]: &#106;avascript:window.__xss=1',
});
cy.get('#dynamicModalWarningText').find('a').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('neutralises raw HTML', () => {
open({
warning: '<img src=x onerror="window.__xss = true">',
info: '<a href="javascript:window.__xss = true">x</a>',
});
cy.get('#dynamicModalWarningText').find('img').should('not.exist');
cy.get('#dynamicModalWarningText').should('contain.text', 'onerror');
cy.get('#dynamicModalInfoText').find('a').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('neutralises raw HTML used as the text of a stripped link', () => {
open({ warning: '[<img src=x onerror="window.__xss = true">](javascript:bad)' });
cy.get('#dynamicModalWarningText').find('img').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
fix(app): stop trusting X-Forwarded-For, and pin what the audit found ProxyFix defaults x_for to 1, so ProxyFix(app.wsgi_app, x_proto=1) never disabled it: request.remote_addr and the access log were forgeable by any client that reached the app directly. It is x_for=0 now, asserted rather than assumed. A mutation audit over the change set reverted 196 deliberate behaviours and found 47 that no test noticed. This closes the ones that carry damage: - apod_background lost its key check, its transport guard, its status guard and its media-type check without a single test failing. Each one turns a slow or unhappy NASA into a 500 on every page. - Untrusted values reached innerHTML through window.I18N, which the translation backend writes, and the modal's click handlers stacked so a later click opened an earlier popup's URL. - The sync tool could ask for HTML instead of text, translate from "auto" instead of English, run without a timeout, store an empty translation that marks the string done for good, abandon 28 languages because one could not be written, and report success after reaching nothing. - Neither the lint target, the CI jobs, the vendored RTL stylesheet, the documented environment keys, nor any of the four hardenings in scripts/run-e2e.sh was observed by anything. Three of the new tests passed for the wrong reason on their first cut — a mock that answered None whether or not the guard existed, a raise_for_status that was never called, a string that stayed in the file after the mutation. The audit found those too; all 24 reverts now fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 10:02:54 +02:00
it('keeps a relative link', () => {
open({ warning: 'See [the notes](/#anchor)' });
cy.get('#dynamicModalWarningText')
.find('a')
.should('have.attr', 'href', '/#anchor');
});
it('keeps the text of a link it strips', () => {
open({ warning: '[read this](javascript:window.__xss=1)' });
cy.get('#dynamicModalWarningText').should('contain.text', 'read this');
cy.window().should('not.have.property', '__xss');
});
fix(modal): stop untrusted content reaching innerHTML and the iframe Every one of these paths checked a string that the browser reinterprets afterwards. isSafeUrl now hangs the value on an <a> and reads back probe.protocol, so the check sees what the browser will see: a pre-parse test reads "&#106;avascript:" as a relative path and passes it, and the HTML parser then decodes it to "javascript:". marked passes raw HTML through and emits hrefs unescaped. renderMarkdown escapes the angle brackets before parsing, parses into an inert DOMParser document where no script runs and no image loads, and drops anchors and images whose scheme is not http, https or mailto. Blockquotes and <autolinks> stop working as a result; neither appears in the configuration. modalTitle and the alternatives list interpolated subitem.name and icon.class into innerHTML. Both are built as nodes now. name is a translatable key, so it arrives from the machine-written catalogues. The link kept its click handler and its class across popups, because one anchor serves all of them: a later, unrelated click opened whatever an earlier popup pointed at, and addEventListener stacked one handler per open. Both are reset per popup and the handler is assigned, not added. openIframe guards its own argument. Removing the href alone left the handler passing the raw URL on, and ?iframe= in the query string reaches the same sink with no configuration involved at all. Verified in headless Chromium: decimal and hex character references, &Tab;- and &NewLine;-split schemes, reference-style links, raw HTML as a link's text, and the two name sinks all executed before these changes. injection.spec.js keeps all fifteen payloads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:04:25 +02:00
it('keeps ordinary markdown', () => {
open({ warning: 'See [Matrix](https://matrix.org/) and **mind** this' });
cy.get('#dynamicModalWarningText')
.find('a')
.should('have.attr', 'href', 'https://matrix.org/');
cy.get('#dynamicModalWarningText').find('strong').should('have.text', 'mind');
});
});
describe('values interpolated outside markdown', () => {
fix(app): stop trusting X-Forwarded-For, and pin what the audit found ProxyFix defaults x_for to 1, so ProxyFix(app.wsgi_app, x_proto=1) never disabled it: request.remote_addr and the access log were forgeable by any client that reached the app directly. It is x_for=0 now, asserted rather than assumed. A mutation audit over the change set reverted 196 deliberate behaviours and found 47 that no test noticed. This closes the ones that carry damage: - apod_background lost its key check, its transport guard, its status guard and its media-type check without a single test failing. Each one turns a slow or unhappy NASA into a 500 on every page. - Untrusted values reached innerHTML through window.I18N, which the translation backend writes, and the modal's click handlers stacked so a later click opened an earlier popup's URL. - The sync tool could ask for HTML instead of text, translate from "auto" instead of English, run without a timeout, store an empty translation that marks the string done for good, abandon 28 languages because one could not be written, and report success after reaching nothing. - Neither the lint target, the CI jobs, the vendored RTL stylesheet, the documented environment keys, nor any of the four hardenings in scripts/run-e2e.sh was observed by anything. Three of the new tests passed for the wrong reason on their first cut — a mock that answered None whether or not the guard existed, a raise_for_status that was never called, a string that stayed in the file after the mutation. The audit found those too; all 24 reverts now fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 10:02:54 +02:00
it('does not treat an interface string as markup', () => {
cy.window().then(win => {
win.I18N.Open = '<img src=x onerror="window.__xss = true">';
});
open({
alternatives: [
{ name: 'Alt', identifier: 'A', icon: { class: 'fa-alt' } },
],
});
cy.get('#dynamicAlternativesList').find('img').should('not.exist');
cy.get('#dynamicAlternativesList').should('contain.text', 'onerror');
cy.window().should('not.have.property', '__xss');
});
it('falls back to the English source when a string is missing', () => {
cy.window().then(win => {
delete win.I18N;
});
open({
alternatives: [
{ name: 'Alt', identifier: 'A', icon: { class: 'fa-alt' } },
],
});
cy.get('#dynamicAlternativesList button').should('have.text', 'Open');
});
it('renders no placeholder for a missing name', () => {
open({ name: undefined });
cy.get('#dynamicModalLabel').should('not.contain.text', 'undefined');
});
fix(modal): stop untrusted content reaching innerHTML and the iframe Every one of these paths checked a string that the browser reinterprets afterwards. isSafeUrl now hangs the value on an <a> and reads back probe.protocol, so the check sees what the browser will see: a pre-parse test reads "&#106;avascript:" as a relative path and passes it, and the HTML parser then decodes it to "javascript:". marked passes raw HTML through and emits hrefs unescaped. renderMarkdown escapes the angle brackets before parsing, parses into an inert DOMParser document where no script runs and no image loads, and drops anchors and images whose scheme is not http, https or mailto. Blockquotes and <autolinks> stop working as a result; neither appears in the configuration. modalTitle and the alternatives list interpolated subitem.name and icon.class into innerHTML. Both are built as nodes now. name is a translatable key, so it arrives from the machine-written catalogues. The link kept its click handler and its class across popups, because one anchor serves all of them: a later, unrelated click opened whatever an earlier popup pointed at, and addEventListener stacked one handler per open. Both are reset per popup and the handler is assigned, not added. openIframe guards its own argument. Removing the href alone left the handler passing the raw URL on, and ?iframe= in the query string reaches the same sink with no configuration involved at all. Verified in headless Chromium: decimal and hex character references, &Tab;- and &NewLine;-split schemes, reference-style links, raw HTML as a link's text, and the two name sinks all executed before these changes. injection.spec.js keeps all fifteen payloads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:04:25 +02:00
it('does not treat the name or the icon class as markup', () => {
open({
name: '<img src=x onerror="window.__xss = true">',
icon: { class: 'fa" onmouseover="window.__xss = true' },
alternatives: [
{
name: '<img src=y onerror="window.__xss = true">',
identifier: 'ALT1',
icon: { class: 'fa-alt' },
},
],
});
cy.get('#dynamicModalLabel').find('img').should('not.exist');
cy.get('#dynamicModalLabel').should('contain.text', 'onerror');
cy.get('#dynamicAlternativesList').find('img').should('not.exist');
cy.get('#dynamicAlternativesList').should('contain.text', 'onerror');
cy.window().should('not.have.property', '__xss');
});
});
describe('the link the modal offers', () => {
it('drops a URL that uses an unsafe scheme', () => {
open({ url: 'javascript:window.__xss = true', description: 'Bad' });
cy.get('#dynamicModalLinkHref').should('not.have.attr', 'href');
cy.get('#dynamicModalLinkHref').should('have.text', 'Bad');
cy.window().should('not.have.property', '__xss');
});
it('keeps an ordinary URL', () => {
open({ url: 'https://example.com', description: 'Good' });
cy.get('#dynamicModalLinkHref').should(
'have.attr',
'href',
'https://example.com',
);
});
fix(app): stop trusting X-Forwarded-For, and pin what the audit found ProxyFix defaults x_for to 1, so ProxyFix(app.wsgi_app, x_proto=1) never disabled it: request.remote_addr and the access log were forgeable by any client that reached the app directly. It is x_for=0 now, asserted rather than assumed. A mutation audit over the change set reverted 196 deliberate behaviours and found 47 that no test noticed. This closes the ones that carry damage: - apod_background lost its key check, its transport guard, its status guard and its media-type check without a single test failing. Each one turns a slow or unhappy NASA into a 500 on every page. - Untrusted values reached innerHTML through window.I18N, which the translation backend writes, and the modal's click handlers stacked so a later click opened an earlier popup's URL. - The sync tool could ask for HTML instead of text, translate from "auto" instead of English, run without a timeout, store an empty translation that marks the string done for good, abandon 28 languages because one could not be written, and report success after reaching nothing. - Neither the lint target, the CI jobs, the vendored RTL stylesheet, the documented environment keys, nor any of the four hardenings in scripts/run-e2e.sh was observed by anything. Three of the new tests passed for the wrong reason on their first cut — a mock that answered None whether or not the guard existed, a raise_for_status that was never called, a string that stayed in the file after the mutation. The audit found those too; all 24 reverts now fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 10:02:54 +02:00
it('keeps a URL that carries surrounding whitespace', () => {
open({ url: ' https://example.com ', description: 'Good' });
cy.get('#dynamicModalLinkHref').should('have.attr', 'href');
});
fix(modal): stop untrusted content reaching innerHTML and the iframe Every one of these paths checked a string that the browser reinterprets afterwards. isSafeUrl now hangs the value on an <a> and reads back probe.protocol, so the check sees what the browser will see: a pre-parse test reads "&#106;avascript:" as a relative path and passes it, and the HTML parser then decodes it to "javascript:". marked passes raw HTML through and emits hrefs unescaped. renderMarkdown escapes the angle brackets before parsing, parses into an inert DOMParser document where no script runs and no image loads, and drops anchors and images whose scheme is not http, https or mailto. Blockquotes and <autolinks> stop working as a result; neither appears in the configuration. modalTitle and the alternatives list interpolated subitem.name and icon.class into innerHTML. Both are built as nodes now. name is a translatable key, so it arrives from the machine-written catalogues. The link kept its click handler and its class across popups, because one anchor serves all of them: a later, unrelated click opened whatever an earlier popup pointed at, and addEventListener stacked one handler per open. Both are reset per popup and the handler is assigned, not added. openIframe guards its own argument. Removing the href alone left the handler passing the raw URL on, and ?iframe= in the query string reaches the same sink with no configuration involved at all. Verified in headless Chromium: decimal and hex character references, &Tab;- and &NewLine;-split schemes, reference-style links, raw HTML as a link's text, and the two name sinks all executed before these changes. injection.spec.js keeps all fifteen payloads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:04:25 +02:00
it('keeps a mailto URL', () => {
open({ url: 'mailto:kevin@veen.world', description: 'Write' });
cy.get('#dynamicModalLinkHref').should(
'have.attr',
'href',
'mailto:kevin@veen.world',
);
});
it('restores the link after a popup whose URL was dropped', () => {
open({ url: 'javascript:window.__xss = true', description: 'Bad' });
cy.get('#dynamicModalLinkHref').should('not.have.attr', 'href');
open({ url: 'https://example.com', description: 'Good' });
cy.get('#dynamicModalLinkHref').should(
'have.attr',
'href',
'https://example.com',
);
});
it('does not let one popup iframe handler outlive it', () => {
open({ url: 'https://a.test/', description: 'A', iframe: true });
cy.get('#dynamicModalLinkHref').should('have.class', 'iframe');
open({ url: 'https://b.test/', description: 'B' });
cy.get('#dynamicModalLinkHref').should('not.have.class', 'iframe');
cy.get('#dynamicModalLinkHref').should($anchor => {
expect($anchor[0].onclick, 'stale click handler').to.equal(null);
});
});
fix(app): stop trusting X-Forwarded-For, and pin what the audit found ProxyFix defaults x_for to 1, so ProxyFix(app.wsgi_app, x_proto=1) never disabled it: request.remote_addr and the access log were forgeable by any client that reached the app directly. It is x_for=0 now, asserted rather than assumed. A mutation audit over the change set reverted 196 deliberate behaviours and found 47 that no test noticed. This closes the ones that carry damage: - apod_background lost its key check, its transport guard, its status guard and its media-type check without a single test failing. Each one turns a slow or unhappy NASA into a 500 on every page. - Untrusted values reached innerHTML through window.I18N, which the translation backend writes, and the modal's click handlers stacked so a later click opened an earlier popup's URL. - The sync tool could ask for HTML instead of text, translate from "auto" instead of English, run without a timeout, store an empty translation that marks the string done for good, abandon 28 languages because one could not be written, and report success after reaching nothing. - Neither the lint target, the CI jobs, the vendored RTL stylesheet, the documented environment keys, nor any of the four hardenings in scripts/run-e2e.sh was observed by anything. Three of the new tests passed for the wrong reason on their first cut — a mock that answered None whether or not the guard existed, a raise_for_status that was never called, a string that stayed in the file after the mutation. The audit found those too; all 24 reverts now fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 10:02:54 +02:00
it('opens the current popup URL, not an earlier one', () => {
open({ url: 'https://a.test/', description: 'A', iframe: true });
open({ url: 'https://b.test/', description: 'B', iframe: true });
cy.get('#dynamicModalLinkHref').click();
cy.get('#main')
.find('iframe', { timeout: 4000 })
.should('have.attr', 'src', 'https://b.test/');
});
fix(modal): stop untrusted content reaching innerHTML and the iframe Every one of these paths checked a string that the browser reinterprets afterwards. isSafeUrl now hangs the value on an <a> and reads back probe.protocol, so the check sees what the browser will see: a pre-parse test reads "&#106;avascript:" as a relative path and passes it, and the HTML parser then decodes it to "javascript:". marked passes raw HTML through and emits hrefs unescaped. renderMarkdown escapes the angle brackets before parsing, parses into an inert DOMParser document where no script runs and no image loads, and drops anchors and images whose scheme is not http, https or mailto. Blockquotes and <autolinks> stop working as a result; neither appears in the configuration. modalTitle and the alternatives list interpolated subitem.name and icon.class into innerHTML. Both are built as nodes now. name is a translatable key, so it arrives from the machine-written catalogues. The link kept its click handler and its class across popups, because one anchor serves all of them: a later, unrelated click opened whatever an earlier popup pointed at, and addEventListener stacked one handler per open. Both are reset per popup and the handler is assigned, not added. openIframe guards its own argument. Removing the href alone left the handler passing the raw URL on, and ?iframe= in the query string reaches the same sink with no configuration involved at all. Verified in headless Chromium: decimal and hex character references, &Tab;- and &NewLine;-split schemes, reference-style links, raw HTML as a link's text, and the two name sinks all executed before these changes. injection.spec.js keeps all fifteen payloads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:04:25 +02:00
});
});
describe('Untrusted content reaching the iframe', () => {
const AFTER_THE_FADE = 3000;
it('refuses to open a script URL handed over by the modal', () => {
cy.visit('/');
cy.window().invoke('openDynamicPopup', {
name: 'Bad',
icon: { class: 'fa fa-test' },
url: 'javascript:window.__xss = true',
description: 'Watch',
iframe: true,
});
cy.get('#dynamicModalLinkHref').click({ force: true });
cy.wait(AFTER_THE_FADE);
cy.get('#main').find('iframe').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('refuses a script URL supplied through the query string', () => {
cy.visit('/?iframe=javascript:window.__xss%20%3D%20true');
cy.wait(AFTER_THE_FADE);
cy.get('#main').find('iframe').should('not.exist');
cy.window().should('not.have.property', '__xss');
});
it('still opens an ordinary URL from the query string', () => {
cy.visit('/?iframe=https://example.com/');
cy.get('#main')
.find('iframe', { timeout: AFTER_THE_FADE })
.should('have.attr', 'src', 'https://example.com/');
});
});