fix(web): restrict standalone video links and cache CSP
- commit
- f58172b83be79e0a3804aba634b471a6f0e2eef2
- author
- brice · · 2026-10-22 14:11 UTC
- committer
- brice · · 2026-10-22 14:34 UTC
- parent
- 7864698
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +52 -11
M .hexcheck.yaml +10 -1
| @@ -26,6 +26,11 @@ components: | |||
| 26 | 26 | role: adapter | |
| 27 | 27 | paths: | |
| 28 | 28 | - internal/httprange/** | |
| 29 | + | mediapolicy: | |
| 30 | + | # Video host validation and CSP sources: shared HTTP infrastructure. | |
| 31 | + | role: adapter | |
| 32 | + | paths: | |
| 33 | + | - internal/mediapolicy/** | |
| 29 | 34 | entrypoints: | |
| 30 | 35 | # cmd/ermite only calls app.Run. | |
| 31 | 36 | role: entrypoint | |
| @@ -94,4 +99,8 @@ allow: | |||
| 94 | 99 | reason: rate-limits API clients with internal/clientlimit | |
| 95 | 100 | - rule: no-adapter-to-adapter-imports | |
| 96 | 101 | path: internal/adapters/web/** | |
| 97 | - | reason: rate-limits login attempts with internal/clientlimit and selects byte ranges with internal/httprange | |
| 102 | + | reason: rate-limits login attempts with internal/clientlimit, selects byte ranges with internal/httprange and shares video hosts with internal/mediapolicy | |
| 103 | + | ||
| 104 | + | - rule: no-adapter-to-adapter-imports | |
| 105 | + | path: internal/adapters/httpserver/** | |
| 106 | + | reason: shares external video hosts and CSP sources with internal/mediapolicy | |
| @@ -26,6 +26,11 @@ components: | |||||
| 26 | role: adapter | 26 | role: adapter | ||
| 27 | paths: | 27 | paths: | ||
| 28 | - internal/httprange/** | 28 | - internal/httprange/** | ||
| 29 | + | mediapolicy: | |||
| 30 | + | # Video host validation and CSP sources: shared HTTP infrastructure. | |||
| 31 | + | role: adapter | |||
| 32 | + | paths: | |||
| 33 | + | - internal/mediapolicy/** | |||
| 29 | entrypoints: | 34 | entrypoints: | ||
| 30 | # cmd/ermite only calls app.Run. | 35 | # cmd/ermite only calls app.Run. | ||
| 31 | role: entrypoint | 36 | role: entrypoint | ||
| @@ -94,4 +99,8 @@ allow: | |||||
| 94 | reason: rate-limits API clients with internal/clientlimit | 99 | reason: rate-limits API clients with internal/clientlimit | ||
| 95 | - rule: no-adapter-to-adapter-imports | 100 | - rule: no-adapter-to-adapter-imports | ||
| 96 | path: internal/adapters/web/** | 101 | path: internal/adapters/web/** | ||
| 97 | - | reason: rate-limits login attempts with internal/clientlimit and selects byte ranges with internal/httprange | 102 | + | reason: rate-limits login attempts with internal/clientlimit, selects byte ranges with internal/httprange and shares video hosts with internal/mediapolicy |
| 103 | + | ||||
| 104 | + | - rule: no-adapter-to-adapter-imports | |||
| 105 | + | path: internal/adapters/httpserver/** | |||
| 106 | + | reason: shares external video hosts and CSP sources with internal/mediapolicy | |||
M docs/configuration.md +4 -2
| @@ -102,8 +102,10 @@ This build uses storage schema 6. Once it has run on your data, an older ermite | |||
| 102 | 102 | Trusted Markdown renders video images (``, also `.webm`, `.mov`, | |
| 103 | 103 | `.m4v` and `.ogv`) as native players. Repository-relative destinations use the raw | |
| 104 | 104 | route of the viewed ref; external HTTP(S) destinations require an | |
| 105 | - | `ERMITE_VIDEO_HOSTS` host. A standalone link or autolink to an allowed host also | |
| 106 | - | renders as a player, including extensionless GitHub user-attachment URLs. | |
| 105 | + | `ERMITE_VIDEO_HOSTS` host. A standalone link or autolink to an allowed host | |
| 106 | + | renders as a player only when its path has a supported video extension or is an | |
| 107 | + | extensionless GitHub `github.com/user-attachments/assets/<id>` URL. Repository, | |
| 108 | + | issue and other ordinary URLs stay links. | |
| 107 | 109 | Other images show their alt text; other links keep the existing link policy. | |
| 108 | 110 | Raw HTML stays disabled. Untrusted public issue Markdown never loads videos. | |
| 109 | 111 | Raw video types are detected by bytes, not extensions. Seeking supports single | |
| @@ -102,8 +102,10 @@ This build uses storage schema 6. Once it has run on your data, an older ermite | |||||
| 102 | Trusted Markdown renders video images (``, also `.webm`, `.mov`, | 102 | Trusted Markdown renders video images (``, also `.webm`, `.mov`, | ||
| 103 | `.m4v` and `.ogv`) as native players. Repository-relative destinations use the raw | 103 | `.m4v` and `.ogv`) as native players. Repository-relative destinations use the raw | ||
| 104 | route of the viewed ref; external HTTP(S) destinations require an | 104 | route of the viewed ref; external HTTP(S) destinations require an | ||
| 105 | - | `ERMITE_VIDEO_HOSTS` host. A standalone link or autolink to an allowed host also | 105 | + | `ERMITE_VIDEO_HOSTS` host. A standalone link or autolink to an allowed host |
| 106 | - | renders as a player, including extensionless GitHub user-attachment URLs. | 106 | + | renders as a player only when its path has a supported video extension or is an |
| 107 | + | extensionless GitHub `github.com/user-attachments/assets/<id>` URL. Repository, | |||
| 108 | + | issue and other ordinary URLs stay links. | |||
| 107 | Other images show their alt text; other links keep the existing link policy. | 109 | Other images show their alt text; other links keep the existing link policy. | ||
| 108 | Raw HTML stays disabled. Untrusted public issue Markdown never loads videos. | 110 | Raw HTML stays disabled. Untrusted public issue Markdown never loads videos. | ||
| 109 | Raw video types are detected by bytes, not extensions. Seeking supports single | 111 | Raw video types are detected by bytes, not extensions. Seeking supports single | ||
M internal/adapters/httpserver/middleware.go +2 -1
| @@ -33,9 +33,10 @@ func isHTTPS(publicURL string) bool { | |||
| 33 | 33 | // the handler runs, so that errors and 404s carry them too. With hsts, the | |
| 34 | 34 | // browser is told to stay on https. | |
| 35 | 35 | func withSecurityHeaders(next http.Handler, hsts bool, hosts []string) http.Handler { | |
| 36 | + | csp := contentSecurityPolicy + "; media-src 'self' " + mediapolicy.Sources(hosts) | |
| 36 | 37 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 37 | 38 | h := w.Header() | |
| 38 | - | h.Set("Content-Security-Policy", contentSecurityPolicy+"; media-src 'self' "+mediapolicy.Sources(hosts)) | |
| 39 | + | h.Set("Content-Security-Policy", csp) | |
| 39 | 40 | h.Set("X-Frame-Options", "DENY") | |
| 40 | 41 | h.Set("X-Content-Type-Options", "nosniff") | |
| 41 | 42 | h.Set("Referrer-Policy", "same-origin") | |
| @@ -33,9 +33,10 @@ func isHTTPS(publicURL string) bool { | |||||
| 33 | // the handler runs, so that errors and 404s carry them too. With hsts, the | 33 | // the handler runs, so that errors and 404s carry them too. With hsts, the | ||
| 34 | // browser is told to stay on https. | 34 | // browser is told to stay on https. | ||
| 35 | func withSecurityHeaders(next http.Handler, hsts bool, hosts []string) http.Handler { | 35 | func withSecurityHeaders(next http.Handler, hsts bool, hosts []string) http.Handler { | ||
| 36 | + | csp := contentSecurityPolicy + "; media-src 'self' " + mediapolicy.Sources(hosts) | |||
| 36 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 37 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 37 | h := w.Header() | 38 | h := w.Header() | ||
| 38 | - | h.Set("Content-Security-Policy", contentSecurityPolicy+"; media-src 'self' "+mediapolicy.Sources(hosts)) | 39 | + | h.Set("Content-Security-Policy", csp) |
| 39 | h.Set("X-Frame-Options", "DENY") | 40 | h.Set("X-Frame-Options", "DENY") | ||
| 40 | h.Set("X-Content-Type-Options", "nosniff") | 41 | h.Set("X-Content-Type-Options", "nosniff") | ||
| 41 | h.Set("Referrer-Policy", "same-origin") | 42 | h.Set("Referrer-Policy", "same-origin") | ||
M internal/adapters/web/markdown.go +26 -7
| @@ -39,7 +39,8 @@ const headingIDPrefix = "md-" | |||
| 39 | 39 | // protocol-relative //host, empty targets…) loses its link: the text stays; | |
| 40 | 40 | // - video images (.mp4, .webm, .mov, .m4v, .ogv) become native players | |
| 41 | 41 | // from the raw route or an allowlisted HTTP(S) host; other images show alt text; | |
| 42 | - | // - standalone links and autolinks to allowlisted video hosts become players. | |
| 42 | + | // - standalone links and autolinks to allowlisted hosts become players only | |
| 43 | + | // for video extensions or GitHub /user-attachments/assets/<id> paths. | |
| 43 | 44 | type markdown struct { | |
| 44 | 45 | md goldmark.Markdown | |
| 45 | 46 | videoHosts []string | |
| @@ -529,12 +530,8 @@ func (p linkPolicy) videoTarget(dest string, requireExtension bool) (string, boo | |||
| 529 | 530 | if err != nil { | |
| 530 | 531 | return "", false | |
| 531 | 532 | } | |
| 532 | - | if requireExtension { | |
| 533 | - | switch strings.ToLower(path.Ext(u.Path)) { | |
| 534 | - | case ".mp4", ".webm", ".mov", ".m4v", ".ogv": | |
| 535 | - | default: | |
| 536 | - | return "", false | |
| 537 | - | } | |
| 533 | + | if requireExtension && !videoExtension(u.Path) { | |
| 534 | + | return "", false | |
| 538 | 535 | } | |
| 539 | 536 | p.rawVideo = true | |
| 540 | 537 | target, kind := p.target(dest) | |
| @@ -556,6 +553,9 @@ func (p linkPolicy) standaloneVideo(n ast.Node, dest, label string) bool { | |||
| 556 | 553 | if err != nil || !mediapolicy.Allows(u, p.videoHosts) { | |
| 557 | 554 | return false | |
| 558 | 555 | } | |
| 556 | + | if !videoExtension(u.Path) && !githubAttachment(u) { | |
| 557 | + | return false | |
| 558 | + | } | |
| 559 | 559 | src, ok := p.videoTarget(dest, false) | |
| 560 | 560 | if !ok { | |
| 561 | 561 | return false | |
| @@ -573,3 +573,22 @@ type videoNode struct { | |||
| 573 | 573 | ||
| 574 | 574 | func (n *videoNode) Kind() ast.NodeKind { return kindVideo } | |
| 575 | 575 | func (n *videoNode) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) } | |
| 576 | + | ||
| 577 | + | func videoExtension(name string) bool { | |
| 578 | + | switch strings.ToLower(path.Ext(name)) { | |
| 579 | + | case ".mp4", ".webm", ".mov", ".m4v", ".ogv": | |
| 580 | + | return true | |
| 581 | + | default: | |
| 582 | + | return false | |
| 583 | + | } | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | // githubAttachment recognises only GitHub's extensionless attachment route, | |
| 587 | + | // not arbitrary extensionless pages on a host that also serves video files. | |
| 588 | + | func githubAttachment(u *url.URL) bool { | |
| 589 | + | if !strings.EqualFold(u.Hostname(), "github.com") { | |
| 590 | + | return false | |
| 591 | + | } | |
| 592 | + | id, ok := strings.CutPrefix(u.Path, "/user-attachments/assets/") | |
| 593 | + | return ok && id != "" && id != "." && id != ".." && !strings.Contains(id, "/") && path.Ext(id) == "" | |
| 594 | + | } | |
| @@ -39,7 +39,8 @@ const headingIDPrefix = "md-" | |||||
| 39 | // protocol-relative //host, empty targets…) loses its link: the text stays; | 39 | // protocol-relative //host, empty targets…) loses its link: the text stays; | ||
| 40 | // - video images (.mp4, .webm, .mov, .m4v, .ogv) become native players | 40 | // - video images (.mp4, .webm, .mov, .m4v, .ogv) become native players | ||
| 41 | // from the raw route or an allowlisted HTTP(S) host; other images show alt text; | 41 | // from the raw route or an allowlisted HTTP(S) host; other images show alt text; | ||
| 42 | - | // - standalone links and autolinks to allowlisted video hosts become players. | 42 | + | // - standalone links and autolinks to allowlisted hosts become players only |
| 43 | + | // for video extensions or GitHub /user-attachments/assets/<id> paths. | |||
| 43 | type markdown struct { | 44 | type markdown struct { | ||
| 44 | md goldmark.Markdown | 45 | md goldmark.Markdown | ||
| 45 | videoHosts []string | 46 | videoHosts []string | ||
| @@ -529,12 +530,8 @@ func (p linkPolicy) videoTarget(dest string, requireExtension bool) (string, boo | |||||
| 529 | if err != nil { | 530 | if err != nil { | ||
| 530 | return "", false | 531 | return "", false | ||
| 531 | } | 532 | } | ||
| 532 | - | if requireExtension { | 533 | + | if requireExtension && !videoExtension(u.Path) { |
| 533 | - | switch strings.ToLower(path.Ext(u.Path)) { | 534 | + | return "", false |
| 534 | - | case ".mp4", ".webm", ".mov", ".m4v", ".ogv": | |||
| 535 | - | default: | |||
| 536 | - | return "", false | |||
| 537 | - | } | |||
| 538 | } | 535 | } | ||
| 539 | p.rawVideo = true | 536 | p.rawVideo = true | ||
| 540 | target, kind := p.target(dest) | 537 | target, kind := p.target(dest) | ||
| @@ -556,6 +553,9 @@ func (p linkPolicy) standaloneVideo(n ast.Node, dest, label string) bool { | |||||
| 556 | if err != nil || !mediapolicy.Allows(u, p.videoHosts) { | 553 | if err != nil || !mediapolicy.Allows(u, p.videoHosts) { | ||
| 557 | return false | 554 | return false | ||
| 558 | } | 555 | } | ||
| 556 | + | if !videoExtension(u.Path) && !githubAttachment(u) { | |||
| 557 | + | return false | |||
| 558 | + | } | |||
| 559 | src, ok := p.videoTarget(dest, false) | 559 | src, ok := p.videoTarget(dest, false) | ||
| 560 | if !ok { | 560 | if !ok { | ||
| 561 | return false | 561 | return false | ||
| @@ -573,3 +573,22 @@ type videoNode struct { | |||||
| 573 | 573 | ||||
| 574 | func (n *videoNode) Kind() ast.NodeKind { return kindVideo } | 574 | func (n *videoNode) Kind() ast.NodeKind { return kindVideo } | ||
| 575 | func (n *videoNode) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) } | 575 | func (n *videoNode) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) } | ||
| 576 | + | ||||
| 577 | + | func videoExtension(name string) bool { | |||
| 578 | + | switch strings.ToLower(path.Ext(name)) { | |||
| 579 | + | case ".mp4", ".webm", ".mov", ".m4v", ".ogv": | |||
| 580 | + | return true | |||
| 581 | + | default: | |||
| 582 | + | return false | |||
| 583 | + | } | |||
| 584 | + | } | |||
| 585 | + | ||||
| 586 | + | // githubAttachment recognises only GitHub's extensionless attachment route, | |||
| 587 | + | // not arbitrary extensionless pages on a host that also serves video files. | |||
| 588 | + | func githubAttachment(u *url.URL) bool { | |||
| 589 | + | if !strings.EqualFold(u.Hostname(), "github.com") { | |||
| 590 | + | return false | |||
| 591 | + | } | |||
| 592 | + | id, ok := strings.CutPrefix(u.Path, "/user-attachments/assets/") | |||
| 593 | + | return ok && id != "" && id != "." && id != ".." && !strings.Contains(id, "/") && path.Ext(id) == "" | |||
| 594 | + | } | |||
M internal/adapters/web/markdown_test.go +10 -0
| @@ -398,6 +398,16 @@ func TestMarkdownVideos(t *testing.T) { | |||
| 398 | 398 | {"autolink", `https://github.com/user-attachments/assets/uuid`, `src="https://github.com/user-attachments/assets/uuid"`, true}, | |
| 399 | 399 | {"angle autolink", `<https://github.com/user-attachments/assets/uuid>`, `src="https://github.com/user-attachments/assets/uuid"`, true}, | |
| 400 | 400 | {"standalone link", `[clip](https://github.com/user-attachments/assets/uuid)`, `aria-label="clip"`, true}, | |
| 401 | + | {"repo link", `[Source](https://github.com/org/project)`, `<a href="https://github.com/org/project"`, false}, | |
| 402 | + | {"issue link", `[Issue](https://github.com/org/project/issues/123)`, `<a href="https://github.com/org/project/issues/123"`, false}, | |
| 403 | + | {"root autolink", `https://github.com`, `<a href="https://github.com"`, false}, | |
| 404 | + | {"root link", `[GitHub](https://github.com/)`, `<a href="https://github.com/"`, false}, | |
| 405 | + | {"standalone video extension", `[clip](https://user-images.githubusercontent.com/demo.MP4?download=1)`, `<video controls`, true}, | |
| 406 | + | {"video autolink", `https://github.com/demo.webm`, `<video controls`, true}, | |
| 407 | + | {"attachment missing id", `https://github.com/user-attachments/assets/`, `<a href=`, false}, | |
| 408 | + | {"attachment extra segment", `https://github.com/user-attachments/assets/id/extra`, `<a href=`, false}, | |
| 409 | + | {"attachment nonvideo extension", `https://github.com/user-attachments/assets/id.png`, `<a href=`, false}, | |
| 410 | + | {"attachment other host", `https://user-images.githubusercontent.com/user-attachments/assets/id`, `<a href=`, false}, | |
| 401 | 411 | {"inline link", `see [clip](https://github.com/user-attachments/assets/uuid) here`, `<a href=`, false}, | |
| 402 | 412 | {"blocked image", ``, `<span class="alt">[demo]</span>`, false}, | |
| 403 | 413 | {"blocked link", `https://evil.example/demo.mp4`, `<a href=`, false}, | |
| @@ -398,6 +398,16 @@ func TestMarkdownVideos(t *testing.T) { | |||||
| 398 | {"autolink", `https://github.com/user-attachments/assets/uuid`, `src="https://github.com/user-attachments/assets/uuid"`, true}, | 398 | {"autolink", `https://github.com/user-attachments/assets/uuid`, `src="https://github.com/user-attachments/assets/uuid"`, true}, | ||
| 399 | {"angle autolink", `<https://github.com/user-attachments/assets/uuid>`, `src="https://github.com/user-attachments/assets/uuid"`, true}, | 399 | {"angle autolink", `<https://github.com/user-attachments/assets/uuid>`, `src="https://github.com/user-attachments/assets/uuid"`, true}, | ||
| 400 | {"standalone link", `[clip](https://github.com/user-attachments/assets/uuid)`, `aria-label="clip"`, true}, | 400 | {"standalone link", `[clip](https://github.com/user-attachments/assets/uuid)`, `aria-label="clip"`, true}, | ||
| 401 | + | {"repo link", `[Source](https://github.com/org/project)`, `<a href="https://github.com/org/project"`, false}, | |||
| 402 | + | {"issue link", `[Issue](https://github.com/org/project/issues/123)`, `<a href="https://github.com/org/project/issues/123"`, false}, | |||
| 403 | + | {"root autolink", `https://github.com`, `<a href="https://github.com"`, false}, | |||
| 404 | + | {"root link", `[GitHub](https://github.com/)`, `<a href="https://github.com/"`, false}, | |||
| 405 | + | {"standalone video extension", `[clip](https://user-images.githubusercontent.com/demo.MP4?download=1)`, `<video controls`, true}, | |||
| 406 | + | {"video autolink", `https://github.com/demo.webm`, `<video controls`, true}, | |||
| 407 | + | {"attachment missing id", `https://github.com/user-attachments/assets/`, `<a href=`, false}, | |||
| 408 | + | {"attachment extra segment", `https://github.com/user-attachments/assets/id/extra`, `<a href=`, false}, | |||
| 409 | + | {"attachment nonvideo extension", `https://github.com/user-attachments/assets/id.png`, `<a href=`, false}, | |||
| 410 | + | {"attachment other host", `https://user-images.githubusercontent.com/user-attachments/assets/id`, `<a href=`, false}, | |||
| 401 | {"inline link", `see [clip](https://github.com/user-attachments/assets/uuid) here`, `<a href=`, false}, | 411 | {"inline link", `see [clip](https://github.com/user-attachments/assets/uuid) here`, `<a href=`, false}, | ||
| 402 | {"blocked image", ``, `<span class="alt">[demo]</span>`, false}, | 412 | {"blocked image", ``, `<span class="alt">[demo]</span>`, false}, | ||
| 403 | {"blocked link", `https://evil.example/demo.mp4`, `<a href=`, false}, | 413 | {"blocked link", `https://evil.example/demo.mp4`, `<a href=`, false}, | ||