fix(security): safe links and security headers (FLAW-28, FLAW-29)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m48s
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:
1 parent
3ca4ff7bce
commit
b2ecda9896
13 files changed
+175
-28
No files matched your search
@@ -3,6 +3,7 @@ import { AuthStatus } from '../lib/types';
|
||||
import { api } from '../lib/api';
|
||||
import { MarkdownViewer } from '../lib/markdown';
|
||||
import { ReadingModal } from './ReadingModal';
|
||||
import { safeHref } from '../lib/safeUrl';
|
||||
|
||||
type LegalType = 'imprint' | 'privacy';
|
||||
const TITLES: Record<LegalType, string> = { imprint: 'Impressum', privacy: 'Datenschutz' };
|
||||
@@ -41,8 +42,8 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-[11px] text-muted ${className}`}>
|
||||
{entries.map((e) =>
|
||||
e.url ? (
|
||||
<a key={e.type} href={e.url} target="_blank" rel="noopener noreferrer" className="hover:text-main underline-offset-2 hover:underline">
|
||||
e.url && safeHref(e.url) ? (
|
||||
<a key={e.type} href={safeHref(e.url) as string} target="_blank" rel="noopener noreferrer" className="hover:text-main underline-offset-2 hover:underline">
|
||||
{TITLES[e.type]}
|
||||
</a>
|
||||
) : (
|
||||
|
||||
@@ -25,6 +25,7 @@ import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
import { LinkModal } from './LinkModal';
|
||||
import { renderInlineMarkdown } from '../../lib/markdown';
|
||||
import { api } from '../../lib/api';
|
||||
import { safeHref } from '../../lib/safeUrl';
|
||||
|
||||
export interface SlashCommand {
|
||||
type: BlockType | 'link';
|
||||
@@ -1001,9 +1002,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
);
|
||||
}
|
||||
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
|
||||
if (mdMatch) {
|
||||
if (mdMatch && safeHref(mdMatch[2])) {
|
||||
const label = mdMatch[1];
|
||||
const url = mdMatch[2];
|
||||
const url = safeHref(mdMatch[2]) as string;
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
@@ -1018,11 +1019,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (/^https?:\/\//.test(part)) {
|
||||
if (/^https?:\/\//.test(part) && safeHref(part)) {
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={part}
|
||||
href={safeHref(part) as string}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,10 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
||||
// Service worker for the app shell (offline start, see public/sw.js)
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user