fix(security): safe links and security headers (FLAW-28, FLAW-29)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m48s

safeHref lets only http, https and mailto become links (Markdown viewer,
outliner, legal links); javascript: and friends show as text. A
middleware sets a Content-Security-Policy (scripts only from MindSift,
no framing, connections only to this server), X-Frame-Options,
nosniff, Referrer-Policy and a Permissions-Policy. The two inline
scripts of index.html moved to files so script-src stays 'self'.

The second inline script also deleted every cache on each page load,
which undermined the offline start; it is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 19:02:13 +01:00
1 parent 3ca4ff7bce
commit b2ecda9896
13 files changed
+175 -28

No files matched your search

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { safeHref } from '../safeUrl';
describe('safeHref', () => {
it('keeps web and mail links', () => {
expect(safeHref('https://example.com/a?b=1')).toBe('https://example.com/a?b=1');
expect(safeHref(' http://example.com ')).toBe('http://example.com/');
expect(safeHref('mailto:anna@example.com')).toBe('mailto:anna@example.com');
});
it('drops everything that could run code or load local data', () => {
for (const bad of [
'javascript:alert(1)',
'JaVaScRiPt:alert(1)',
' javascript:alert(1)',
'java\tscript:alert(1)',
'java\nscript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'vbscript:msgbox(1)',
'file:///etc/passwd',
'/relative/path',
'',
]) {
expect(safeHref(bad)).toBeNull();
}
});
});
describe('Markdown links in task notes', () => {
it('render javascript: links as text, web links as links', async () => {
const { render } = await import('@testing-library/react');
const { MarkdownViewer } = await import('../markdown');
const { container } = render(<MarkdownViewer content={'[Rechnung](javascript:alert(1)) und [Seite](https://example.com)'} />);
const links = Array.from(container.querySelectorAll('a'));
expect(links.map((a) => a.getAttribute('href'))).toEqual(['https://example.com/']);
expect(container.textContent).toContain('Rechnung');
});
});
+3 -2
View File
@@ -1,4 +1,5 @@
import React from 'react';
import { safeHref } from './safeUrl';
/**
* Parses inline markdown:
@@ -86,9 +87,9 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
{token.slice(2, -2)}
</del>
);
} else if (token.startsWith('[') && match[2] && match[3]) {
} else if (token.startsWith('[') && match[2] && match[3] && safeHref(match[3])) {
const label = match[2];
const url = match[3];
const url = safeHref(match[3]) as string;
elements.push(
<a
key={key}
+16
View File
@@ -0,0 +1,16 @@
// Links from user content (notes, task notes, Markdown) may only point to web pages or mail addresses.
// Anything else – javascript:, data:, vbscript:, file: and friends – would run or load in the app's
// context when clicked, so it is shown as plain text instead (FLAW-28).
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
export function safeHref(raw: string): string | null {
const value = raw.trim();
if (!value) return null;
try {
// Browsers ignore control characters and whitespace inside the scheme ("java\tscript:"); URL does the same
const url = new URL(value);
return ALLOWED_PROTOCOLS.has(url.protocol) ? url.href : null;
} catch {
return null; // relative or broken: not a link
}
}