detached 8020e7b

fix(web): restrict standalone video links and cache CSP

commit
8020e7bf70e68858d12c09ecb0e2320bdfa6b706
author
brice · · 2026-10-22 14:11 UTC
parent
67478b4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
3/4
5 files · +52 -11
M .hexcheck.yaml +10 -1
@@ -26,6 +26,11 @@ components:
2626 role: adapter
2727 paths:
2828 - internal/httprange/**
29+ mediapolicy:
30+ # Video host validation and CSP sources: shared HTTP infrastructure.
31+ role: adapter
32+ paths:
33+ - internal/mediapolicy/**
2934 entrypoints:
3035 # cmd/ermite only calls app.Run.
3136 role: entrypoint
@@ -94,4 +99,8 @@ allow:
9499 reason: rate-limits API clients with internal/clientlimit
95100 - rule: no-adapter-to-adapter-imports
96101 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: adapter26 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: entrypoint36 role: entrypoint
@@ -94,4 +99,8 @@ allow:
94 reason: rate-limits API clients with internal/clientlimit99 reason: rate-limits API clients with internal/clientlimit
95 - rule: no-adapter-to-adapter-imports100 - 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/httprange102+ 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
102102 Trusted Markdown renders video images (`![label](clip.mp4)`, also `.webm`, `.mov`,
103103 `.m4v` and `.ogv`) as native players. Repository-relative destinations use the raw
104104 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.
107109 Other images show their alt text; other links keep the existing link policy.
108110 Raw HTML stays disabled. Untrusted public issue Markdown never loads videos.
109111 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 (`![label](clip.mp4)`, also `.webm`, `.mov`,102 Trusted Markdown renders video images (`![label](clip.mp4)`, also `.webm`, `.mov`,
103 `.m4v` and `.ogv`) as native players. Repository-relative destinations use the raw103 `.m4v` and `.ogv`) as native players. Repository-relative destinations use the raw
104 route of the viewed ref; external HTTP(S) destinations require an104 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 also105+`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 single111 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 {
3333 // the handler runs, so that errors and 404s carry them too. With hsts, the
3434 // browser is told to stay on https.
3535 func withSecurityHeaders(next http.Handler, hsts bool, hosts []string) http.Handler {
36+ csp := contentSecurityPolicy + "; media-src 'self' " + mediapolicy.Sources(hosts)
3637 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3738 h := w.Header()
38- h.Set("Content-Security-Policy", contentSecurityPolicy+"; media-src 'self' "+mediapolicy.Sources(hosts))
39+ h.Set("Content-Security-Policy", csp)
3940 h.Set("X-Frame-Options", "DENY")
4041 h.Set("X-Content-Type-Options", "nosniff")
4142 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, the33 // 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-"
3939 // protocol-relative //host, empty targets…) loses its link: the text stays;
4040 // - video images (.mp4, .webm, .mov, .m4v, .ogv) become native players
4141 // 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.
4344 type markdown struct {
4445 md goldmark.Markdown
4546 videoHosts []string
@@ -529,12 +530,8 @@ func (p linkPolicy) videoTarget(dest string, requireExtension bool) (string, boo
529530 if err != nil {
530531 return "", false
531532 }
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
538535 }
539536 p.rawVideo = true
540537 target, kind := p.target(dest)
@@ -556,6 +553,9 @@ func (p linkPolicy) standaloneVideo(n ast.Node, dest, label string) bool {
556553 if err != nil || !mediapolicy.Allows(u, p.videoHosts) {
557554 return false
558555 }
556+ if !videoExtension(u.Path) && !githubAttachment(u) {
557+ return false
558+ }
559559 src, ok := p.videoTarget(dest, false)
560560 if !ok {
561561 return false
@@ -573,3 +573,22 @@ type videoNode struct {
573573
574574 func (n *videoNode) Kind() ast.NodeKind { return kindVideo }
575575 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 players40 // - 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.Markdown45 md goldmark.Markdown
45 videoHosts []string46 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 "", false531 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 = true536 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 false554 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 false561 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) {
398398 {"autolink", `https://github.com/user-attachments/assets/uuid`, `src="https://github.com/user-attachments/assets/uuid"`, true},
399399 {"angle autolink", `<https://github.com/user-attachments/assets/uuid>`, `src="https://github.com/user-attachments/assets/uuid"`, true},
400400 {"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},
401411 {"inline link", `see [clip](https://github.com/user-attachments/assets/uuid) here`, `<a href=`, false},
402412 {"blocked image", `![demo](https://evil.example/demo.mp4)`, `<span class="alt">[demo]</span>`, false},
403413 {"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", `![demo](https://evil.example/demo.mp4)`, `<span class="alt">[demo]</span>`, false},412 {"blocked image", `![demo](https://evil.example/demo.mp4)`, `<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},

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close