detached d4b8b6e

Merge 2c6a32b9c950cb87d2f3cc54c7d8775d67c465ed into 80d5067c3c87121b6ade166b4a1f1ae1046df043

commit
d4b8b6e109afd067414e8521cb6457e5a03c34e3
author
ermite · · 2026-10-22 14:33 UTC
merge of
80d5067 + 2c6a32b · diff against the first parent
signature
unsigned
22 files · +513 -48
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 +16 -0
@@ -25,6 +25,7 @@ ermite serve
2525 | `ERMITE_TRUSTED_PROXIES` | empty | comma-separated CIDRs of your TLS proxy, so login rate limits see the real client (`X-Forwarded-For`) |
2626 | `ERMITE_OWNER` | `owner` | owner login: web header, passkey name, and `<owner>` in CI (`github.repository`). Same rules as repository names |
2727 | `ERMITE_SOURCE_URL` | `$ERMITE_PUBLIC_URL/ermite` | link to the source (AGPL) |
28+| `ERMITE_VIDEO_HOSTS` | `github.com,user-images.githubusercontent.com,private-user-images.githubusercontent.com,github-production-user-asset-6210df.s3.amazonaws.com` | comma-separated exact external video hostnames, replacing the defaults; no URLs, wildcards or ports. The S3 host receives GitHub user-attachment redirects. |
2829 | `ERMITE_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error` |
2930 | `ERMITE_LOG_FORMAT` | `json` | `json` or `console` |
3031
@@ -95,3 +96,18 @@ The hook socket and scripts git calls on each push live in `$ERMITE_DATA_DIR/run
9596 ## Storage schema
9697
9798 This build uses storage schema 6. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
99+
100+## README media
101+
102+Trusted Markdown renders video images (`![label](clip.mp4)`, also `.webm`, `.mov`,
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
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.
109+Other images show their alt text; other links keep the existing link policy.
110+Raw HTML stays disabled. Untrusted public issue Markdown never loads videos.
111+Raw video types are detected by bytes, not extensions. Seeking supports single
112+byte ranges; it streams and discards the preceding bytes, so small clips work
113+best. Multipart ranges are ignored.
@@ -25,6 +25,7 @@ ermite serve
25 | `ERMITE_TRUSTED_PROXIES` | empty | comma-separated CIDRs of your TLS proxy, so login rate limits see the real client (`X-Forwarded-For`) |25 | `ERMITE_TRUSTED_PROXIES` | empty | comma-separated CIDRs of your TLS proxy, so login rate limits see the real client (`X-Forwarded-For`) |
26 | `ERMITE_OWNER` | `owner` | owner login: web header, passkey name, and `<owner>` in CI (`github.repository`). Same rules as repository names |26 | `ERMITE_OWNER` | `owner` | owner login: web header, passkey name, and `<owner>` in CI (`github.repository`). Same rules as repository names |
27 | `ERMITE_SOURCE_URL` | `$ERMITE_PUBLIC_URL/ermite` | link to the source (AGPL) |27 | `ERMITE_SOURCE_URL` | `$ERMITE_PUBLIC_URL/ermite` | link to the source (AGPL) |
28+| `ERMITE_VIDEO_HOSTS` | `github.com,user-images.githubusercontent.com,private-user-images.githubusercontent.com,github-production-user-asset-6210df.s3.amazonaws.com` | comma-separated exact external video hostnames, replacing the defaults; no URLs, wildcards or ports. The S3 host receives GitHub user-attachment redirects. |
28 | `ERMITE_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error` |29 | `ERMITE_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error` |
29 | `ERMITE_LOG_FORMAT` | `json` | `json` or `console` |30 | `ERMITE_LOG_FORMAT` | `json` | `json` or `console` |
30 31
@@ -95,3 +96,18 @@ The hook socket and scripts git calls on each push live in `$ERMITE_DATA_DIR/run
95 ## Storage schema96 ## Storage schema
96 97
97 This build uses storage schema 6. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.98 This build uses storage schema 6. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
99+
100+## README media
101+
102+Trusted Markdown renders video images (`![label](clip.mp4)`, also `.webm`, `.mov`,
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
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.
109+Other images show their alt text; other links keep the existing link policy.
110+Raw HTML stays disabled. Untrusted public issue Markdown never loads videos.
111+Raw video types are detected by bytes, not extensions. Seeking supports single
112+byte ranges; it streams and discards the preceding bytes, so small clips work
113+best. Multipart ranges are ignored.
M docs/issues.md +1 -1
@@ -62,7 +62,7 @@ Limits: a title of at most 120 characters, a description of at most 10,000, an o
6262
6363 The owner moderates in the Inbox, in the first section, **Awaiting moderation**, whose count is in the counters row. Each submission shows its repository, age, name (or "anonymous"), title and the start of the description, as plain text, with three buttons: **Approve** opens it as the next issue of the repository (numbers are shared with merge requests), **Reject** deletes it, **Reject and ban** deletes it and refuses further submissions from the same network for 30 days. The section updates live when a submission arrives.
6464
65-An approved issue is rendered as untrusted Markdown: raw HTML is dropped, images are never loaded (their alt text is shown), only `http`, `https` and `mailto` links are kept, each with `rel="nofollow noopener noreferrer ugc"`, and every other link loses its link and keeps its text.
65+An approved issue is rendered as untrusted Markdown: raw HTML is dropped, images and videos are never loaded (image alt text is shown), only `http`, `https` and `mailto` links are kept, each with `rel="nofollow noopener noreferrer ugc"`, and every other link loses its link and keeps its text.
6666
6767 No IP address is written to disk. The rate limiter keys on the normalized network address (the IPv4 address, or the IPv6 /64) for at most 24 hours and keeps it only in memory. Pending submissions and bans store only a keyed hash (HMAC) of that network, made with a server key.
6868
@@ -62,7 +62,7 @@ Limits: a title of at most 120 characters, a description of at most 10,000, an o
62 62
63 The owner moderates in the Inbox, in the first section, **Awaiting moderation**, whose count is in the counters row. Each submission shows its repository, age, name (or "anonymous"), title and the start of the description, as plain text, with three buttons: **Approve** opens it as the next issue of the repository (numbers are shared with merge requests), **Reject** deletes it, **Reject and ban** deletes it and refuses further submissions from the same network for 30 days. The section updates live when a submission arrives.63 The owner moderates in the Inbox, in the first section, **Awaiting moderation**, whose count is in the counters row. Each submission shows its repository, age, name (or "anonymous"), title and the start of the description, as plain text, with three buttons: **Approve** opens it as the next issue of the repository (numbers are shared with merge requests), **Reject** deletes it, **Reject and ban** deletes it and refuses further submissions from the same network for 30 days. The section updates live when a submission arrives.
64 64
65-An approved issue is rendered as untrusted Markdown: raw HTML is dropped, images are never loaded (their alt text is shown), only `http`, `https` and `mailto` links are kept, each with `rel="nofollow noopener noreferrer ugc"`, and every other link loses its link and keeps its text.65+An approved issue is rendered as untrusted Markdown: raw HTML is dropped, images and videos are never loaded (image alt text is shown), only `http`, `https` and `mailto` links are kept, each with `rel="nofollow noopener noreferrer ugc"`, and every other link loses its link and keeps its text.
66 66
67 No IP address is written to disk. The rate limiter keys on the normalized network address (the IPv4 address, or the IPv6 /64) for at most 24 hours and keeps it only in memory. Pending submissions and bans store only a keyed hash (HMAC) of that network, made with a server key.67 No IP address is written to disk. The rate limiter keys on the normalized network address (the IPv4 address, or the IPv6 /64) for at most 24 hours and keeps it only in memory. Pending submissions and bans store only a keyed hash (HMAC) of that network, made with a server key.
68 68
M internal/adapters/httpserver/middleware.go +5 -2
@@ -10,6 +10,8 @@ import (
1010 "time"
1111
1212 "github.com/bnema/zerowrap"
13+
14+ "bnema.dev/ermite/internal/mediapolicy"
1315 )
1416
1517 // contentSecurityPolicy allows scripts from the origin plus 'unsafe-eval'
@@ -30,10 +32,11 @@ func isHTTPS(publicURL string) bool {
3032 // withSecurityHeaders sets the security headers on every response, before
3133 // the handler runs, so that errors and 404s carry them too. With hsts, the
3234 // browser is told to stay on https.
33-func withSecurityHeaders(next http.Handler, hsts bool) http.Handler {
35+func withSecurityHeaders(next http.Handler, hsts bool, hosts []string) http.Handler {
36+ csp := contentSecurityPolicy + "; media-src 'self' " + mediapolicy.Sources(hosts)
3437 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3538 h := w.Header()
36- h.Set("Content-Security-Policy", contentSecurityPolicy)
39+ h.Set("Content-Security-Policy", csp)
3740 h.Set("X-Frame-Options", "DENY")
3841 h.Set("X-Content-Type-Options", "nosniff")
3942 h.Set("Referrer-Policy", "same-origin")
@@ -10,6 +10,8 @@ import (
10 "time"10 "time"
11 11
12 "github.com/bnema/zerowrap"12 "github.com/bnema/zerowrap"
13+
14+ "bnema.dev/ermite/internal/mediapolicy"
13 )15 )
14 16
15 // contentSecurityPolicy allows scripts from the origin plus 'unsafe-eval'17 // contentSecurityPolicy allows scripts from the origin plus 'unsafe-eval'
@@ -30,10 +32,11 @@ func isHTTPS(publicURL string) bool {
30 // withSecurityHeaders sets the security headers on every response, before32 // withSecurityHeaders sets the security headers on every response, before
31 // 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
32 // browser is told to stay on https.34 // browser is told to stay on https.
33-func withSecurityHeaders(next http.Handler, hsts bool) http.Handler {35+func withSecurityHeaders(next http.Handler, hsts bool, hosts []string) http.Handler {
36+ csp := contentSecurityPolicy + "; media-src 'self' " + mediapolicy.Sources(hosts)
34 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {37 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
35 h := w.Header()38 h := w.Header()
36- h.Set("Content-Security-Policy", contentSecurityPolicy)39+ h.Set("Content-Security-Policy", csp)
37 h.Set("X-Frame-Options", "DENY")40 h.Set("X-Frame-Options", "DENY")
38 h.Set("X-Content-Type-Options", "nosniff")41 h.Set("X-Content-Type-Options", "nosniff")
39 h.Set("Referrer-Policy", "same-origin")42 h.Set("Referrer-Policy", "same-origin")
M internal/adapters/httpserver/server.go +3 -1
@@ -34,6 +34,8 @@ type Route struct {
3434
3535 // Config configures a Server.
3636 type Config struct {
37+ // VideoHosts are exact external video hosts; nil uses the defaults.
38+ VideoHosts []string
3739 // Addr is the listen address, "host:port".
3840 Addr string
3941 // PublicURL is the public base URL of the server. When it is https://,
@@ -76,7 +78,7 @@ func New(cfg Config) *Server {
7678 for _, r := range cfg.Routes {
7779 mux.Handle(r.Pattern, r.Handler)
7880 }
79- return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux, isHTTPS(cfg.PublicURL))), onShutdown: cfg.OnShutdown}
81+ return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux, isHTTPS(cfg.PublicURL), cfg.VideoHosts)), onShutdown: cfg.OnShutdown}
8082 }
8183
8284 // Handler returns the root handler, for tests.
@@ -34,6 +34,8 @@ type Route struct {
34 34
35 // Config configures a Server.35 // Config configures a Server.
36 type Config struct {36 type Config struct {
37+ // VideoHosts are exact external video hosts; nil uses the defaults.
38+ VideoHosts []string
37 // Addr is the listen address, "host:port".39 // Addr is the listen address, "host:port".
38 Addr string40 Addr string
39 // PublicURL is the public base URL of the server. When it is https://,41 // PublicURL is the public base URL of the server. When it is https://,
@@ -76,7 +78,7 @@ func New(cfg Config) *Server {
76 for _, r := range cfg.Routes {78 for _, r := range cfg.Routes {
77 mux.Handle(r.Pattern, r.Handler)79 mux.Handle(r.Pattern, r.Handler)
78 }80 }
79- return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux, isHTTPS(cfg.PublicURL))), onShutdown: cfg.OnShutdown}81+ return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux, isHTTPS(cfg.PublicURL), cfg.VideoHosts)), onShutdown: cfg.OnShutdown}
80 }82 }
81 83
82 // Handler returns the root handler, for tests.84 // Handler returns the root handler, for tests.
M internal/adapters/httpserver/server_test.go +18 -1
@@ -27,7 +27,7 @@ import (
2727 )
2828
2929 var securityHeaders = map[string]string{
30- "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'",
30+ "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'; media-src 'self' https://github.com http://github.com https://user-images.githubusercontent.com http://user-images.githubusercontent.com https://private-user-images.githubusercontent.com http://private-user-images.githubusercontent.com https://github-production-user-asset-6210df.s3.amazonaws.com http://github-production-user-asset-6210df.s3.amazonaws.com",
3131 "X-Frame-Options": "DENY",
3232 "X-Content-Type-Options": "nosniff",
3333 "Referrer-Policy": "same-origin",
@@ -309,3 +309,20 @@ func TestRunListenError(t *testing.T) {
309309 require.Error(t, err)
310310 assert.True(t, strings.Contains(err.Error(), "listen on"), err.Error())
311311 }
312+
313+func TestVideoHostCSP(t *testing.T) {
314+ for _, hosts := range [][]string{{"clips.example"}, {}} {
315+ t.Run(strings.Join(hosts, ","), func(t *testing.T) {
316+ srv := httpserver.New(httpserver.Config{VideoHosts: hosts})
317+ rec := httptest.NewRecorder()
318+ srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/missing", nil))
319+ csp := rec.Header().Get("Content-Security-Policy")
320+ assert.Contains(t, csp, "; media-src 'self'")
321+ assert.NotContains(t, csp, "github.com")
322+ assert.NotContains(t, csp, "unsafe-inline")
323+ if len(hosts) > 0 {
324+ assert.Contains(t, csp, "https://clips.example http://clips.example")
325+ }
326+ })
327+ }
328+}
@@ -27,7 +27,7 @@ import (
27 )27 )
28 28
29 var securityHeaders = map[string]string{29 var securityHeaders = map[string]string{
30- "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'",30+ "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'; media-src 'self' https://github.com http://github.com https://user-images.githubusercontent.com http://user-images.githubusercontent.com https://private-user-images.githubusercontent.com http://private-user-images.githubusercontent.com https://github-production-user-asset-6210df.s3.amazonaws.com http://github-production-user-asset-6210df.s3.amazonaws.com",
31 "X-Frame-Options": "DENY",31 "X-Frame-Options": "DENY",
32 "X-Content-Type-Options": "nosniff",32 "X-Content-Type-Options": "nosniff",
33 "Referrer-Policy": "same-origin",33 "Referrer-Policy": "same-origin",
@@ -309,3 +309,20 @@ func TestRunListenError(t *testing.T) {
309 require.Error(t, err)309 require.Error(t, err)
310 assert.True(t, strings.Contains(err.Error(), "listen on"), err.Error())310 assert.True(t, strings.Contains(err.Error(), "listen on"), err.Error())
311 }311 }
312+
313+func TestVideoHostCSP(t *testing.T) {
314+ for _, hosts := range [][]string{{"clips.example"}, {}} {
315+ t.Run(strings.Join(hosts, ","), func(t *testing.T) {
316+ srv := httpserver.New(httpserver.Config{VideoHosts: hosts})
317+ rec := httptest.NewRecorder()
318+ srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/missing", nil))
319+ csp := rec.Header().Get("Content-Security-Policy")
320+ assert.Contains(t, csp, "; media-src 'self'")
321+ assert.NotContains(t, csp, "github.com")
322+ assert.NotContains(t, csp, "unsafe-inline")
323+ if len(hosts) > 0 {
324+ assert.Contains(t, csp, "https://clips.example http://clips.example")
325+ }
326+ })
327+ }
328+}
M internal/adapters/web/markdown.go +113 -9
@@ -13,6 +13,7 @@ import (
1313 "time"
1414
1515 "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/mediapolicy"
1617 "github.com/yuin/goldmark"
1718 "github.com/yuin/goldmark/ast"
1819 "github.com/yuin/goldmark/extension"
@@ -36,14 +37,17 @@ const headingIDPrefix = "md-"
3637 // - #fragments point at the (prefixed) heading ids;
3738 // - everything else (javascript:, data:, vbscript:, file:, mailto:,
3839 // protocol-relative //host, empty targets…) loses its link: the text stays;
39-// - images are never loaded (the CSP forbids other origins, and the design
40-// shows an image as its alt text): each becomes <span class="alt">[alt]</span>.
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;
42+// - standalone links and autolinks to allowlisted hosts become players only
43+// for video extensions or GitHub /user-attachments/assets/<id> paths.
4144 type markdown struct {
42- md goldmark.Markdown
45+ md goldmark.Markdown
46+ videoHosts []string
4347 }
4448
45-func newMarkdown() *markdown {
46- return &markdown{md: goldmark.New(
49+func newMarkdown(hosts ...string) *markdown {
50+ return &markdown{videoHosts: mediapolicy.Hosts(hosts), md: goldmark.New(
4751 goldmark.WithExtensions(extension.GFM),
4852 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
4953 goldmark.WithRendererOptions(
@@ -94,7 +98,7 @@ type refResolver struct {
9498
9599 // renderUntrusted renders Markdown written by an anonymous visitor (an issue
96100 // approved from the public form). On top of the policy of render, which already
97-// drops raw HTML and never loads an image:
101+// drops raw HTML, this renderer never loads an image or video:
98102 // - only http, https and mailto links are kept, every one with
99103 // rel="nofollow noopener noreferrer ugc"; relative links, #fragments and
100104 // every other scheme lose their link and keep the text;
@@ -108,6 +112,7 @@ func (m *markdown) renderPolicy(source []byte, pol linkPolicy, res refResolver)
108112 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
109113 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
110114 pol.source = source
115+ pol.videoHosts = m.videoHosts
111116 // Collect eligible text before the policy unwraps rejected links: their
112117 // labels must not accidentally become references either.
113118 var refs []*ast.Text
@@ -196,7 +201,9 @@ type linkPolicy struct {
196201 // idPrefix is the prefix of the heading ids that #fragment links point at.
197202 idPrefix string
198203 // untrusted is the policy of text from an anonymous visitor.
199- untrusted bool
204+ untrusted bool
205+ videoHosts []string
206+ rawVideo bool
200207 }
201208
202209 const (
@@ -233,6 +240,9 @@ func (p linkPolicy) apply(doc ast.Node) {
233240 }
234241 for _, n := range links {
235242 l := n.(*ast.Link)
243+ if p.standaloneVideo(l, string(l.Destination), plainText(l, p.source)) {
244+ continue
245+ }
236246 dest, kind := p.target(string(l.Destination))
237247 if kind == dropped {
238248 unwrap(l)
@@ -246,6 +256,9 @@ func (p linkPolicy) apply(doc ast.Node) {
246256 }
247257 for _, n := range autos {
248258 a := n.(*ast.AutoLink)
259+ if p.standaloneVideo(a, string(a.URL(p.source)), string(a.Label(p.source))) {
260+ continue
261+ }
249262 if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {
250263 a.SetAttributeString("rel", p.rel())
251264 continue
@@ -268,12 +281,16 @@ func unwrap(n ast.Node) {
268281 parent.RemoveChild(parent, n)
269282 }
270283
271-// image replaces an image by its alt text.
284+// image replaces a video image with a player, otherwise its alt text.
272285 func (p linkPolicy) image(img *ast.Image) {
273286 alt := plainText(img, p.source)
274287 if alt == "" {
275288 alt = "image"
276289 }
290+ if src, ok := p.videoTarget(string(img.Destination), true); ok {
291+ img.Parent().ReplaceChild(img.Parent(), img, &videoNode{src: src, alt: alt})
292+ return
293+ }
277294 img.Parent().ReplaceChild(img.Parent(), img, &altSpan{alt: alt})
278295 }
279296
@@ -333,7 +350,11 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
333350 rel = strings.TrimPrefix(rel, "/")
334351 href := repoHref(p.repo)
335352 if rel != "" {
336- href = repoHref(p.repo, "blob", p.ref, rel)
353+ route := "blob"
354+ if p.rawVideo {
355+ route = "raw"
356+ }
357+ href = repoHref(p.repo, route, p.ref, rel)
337358 }
338359 if u.Fragment != "" {
339360 href += "#" + url.PathEscape(u.Fragment)
@@ -400,6 +421,17 @@ func (n *altSpan) Dump(source []byte, level int) { ast.DumpHelper(n, source, lev
400421 type altRenderer struct{}
401422
402423 func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
424+ r.Register(kindVideo, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
425+ if entering {
426+ v := n.(*videoNode)
427+ _, _ = w.WriteString(`<video controls preload="metadata" src="`)
428+ _, _ = w.Write(util.EscapeHTML([]byte(v.src)))
429+ _, _ = w.WriteString(`" aria-label="`)
430+ _, _ = w.Write(util.EscapeHTML([]byte(v.alt)))
431+ _, _ = w.WriteString(`"></video>`)
432+ }
433+ return ast.WalkSkipChildren, nil
434+ })
403435 r.Register(kindAlt, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
404436 if entering {
405437 _, _ = w.WriteString(`<span class="alt">[`)
@@ -488,3 +520,75 @@ func references(t *ast.Text, source []byte, res refResolver) {
488520 parent.InsertBefore(parent, t, tail)
489521 parent.RemoveChild(parent, t)
490522 }
523+
524+// videoTarget uses the ordinary destination policy before restricting media origins.
525+func (p linkPolicy) videoTarget(dest string, requireExtension bool) (string, bool) {
526+ if p.untrusted {
527+ return "", false
528+ }
529+ u, err := url.Parse(strings.TrimSpace(dest))
530+ if err != nil {
531+ return "", false
532+ }
533+ if requireExtension && !videoExtension(u.Path) {
534+ return "", false
535+ }
536+ p.rawVideo = true
537+ target, kind := p.target(dest)
538+ if kind == dropped || strings.HasPrefix(target, "#") {
539+ return "", false
540+ }
541+ if kind == external && !mediapolicy.Allows(u, p.videoHosts) {
542+ return "", false
543+ }
544+ return target, true
545+}
546+
547+func (p linkPolicy) standaloneVideo(n ast.Node, dest, label string) bool {
548+ parent := n.Parent()
549+ if parent == nil || parent.Kind() != ast.KindParagraph || parent.ChildCount() != 1 {
550+ return false
551+ }
552+ u, err := url.Parse(dest)
553+ if err != nil || !mediapolicy.Allows(u, p.videoHosts) {
554+ return false
555+ }
556+ if !videoExtension(u.Path) && !githubAttachment(u) {
557+ return false
558+ }
559+ src, ok := p.videoTarget(dest, false)
560+ if !ok {
561+ return false
562+ }
563+ parent.ReplaceChild(parent, n, &videoNode{src: src, alt: label})
564+ return true
565+}
566+
567+var kindVideo = ast.NewNodeKind("Video")
568+
569+type videoNode struct {
570+ ast.BaseInline
571+ src, alt string
572+}
573+
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) }
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+}
@@ -13,6 +13,7 @@ import (
13 "time"13 "time"
14 14
15 "bnema.dev/ermite/internal/domain"15 "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/mediapolicy"
16 "github.com/yuin/goldmark"17 "github.com/yuin/goldmark"
17 "github.com/yuin/goldmark/ast"18 "github.com/yuin/goldmark/ast"
18 "github.com/yuin/goldmark/extension"19 "github.com/yuin/goldmark/extension"
@@ -36,14 +37,17 @@ const headingIDPrefix = "md-"
36 // - #fragments point at the (prefixed) heading ids;37 // - #fragments point at the (prefixed) heading ids;
37 // - everything else (javascript:, data:, vbscript:, file:, mailto:,38 // - everything else (javascript:, data:, vbscript:, file:, mailto:,
38 // protocol-relative //host, empty targets…) loses its link: the text stays;39 // protocol-relative //host, empty targets…) loses its link: the text stays;
39-// - images are never loaded (the CSP forbids other origins, and the design40+// - video images (.mp4, .webm, .mov, .m4v, .ogv) become native players
40-// shows an image as its alt text): each becomes <span class="alt">[alt]</span>.41+// from the raw route or an allowlisted HTTP(S) host; other images show alt text;
42+// - standalone links and autolinks to allowlisted hosts become players only
43+// for video extensions or GitHub /user-attachments/assets/<id> paths.
41 type markdown struct {44 type markdown struct {
42- md goldmark.Markdown45+ md goldmark.Markdown
46+ videoHosts []string
43 }47 }
44 48
45-func newMarkdown() *markdown {49+func newMarkdown(hosts ...string) *markdown {
46- return &markdown{md: goldmark.New(50+ return &markdown{videoHosts: mediapolicy.Hosts(hosts), md: goldmark.New(
47 goldmark.WithExtensions(extension.GFM),51 goldmark.WithExtensions(extension.GFM),
48 goldmark.WithParserOptions(parser.WithAutoHeadingID()),52 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
49 goldmark.WithRendererOptions(53 goldmark.WithRendererOptions(
@@ -94,7 +98,7 @@ type refResolver struct {
94 98
95 // renderUntrusted renders Markdown written by an anonymous visitor (an issue99 // renderUntrusted renders Markdown written by an anonymous visitor (an issue
96 // approved from the public form). On top of the policy of render, which already100 // approved from the public form). On top of the policy of render, which already
97-// drops raw HTML and never loads an image:101+// drops raw HTML, this renderer never loads an image or video:
98 // - only http, https and mailto links are kept, every one with102 // - only http, https and mailto links are kept, every one with
99 // rel="nofollow noopener noreferrer ugc"; relative links, #fragments and103 // rel="nofollow noopener noreferrer ugc"; relative links, #fragments and
100 // every other scheme lose their link and keep the text;104 // every other scheme lose their link and keep the text;
@@ -108,6 +112,7 @@ func (m *markdown) renderPolicy(source []byte, pol linkPolicy, res refResolver)
108 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))112 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
109 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))113 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
110 pol.source = source114 pol.source = source
115+ pol.videoHosts = m.videoHosts
111 // Collect eligible text before the policy unwraps rejected links: their116 // Collect eligible text before the policy unwraps rejected links: their
112 // labels must not accidentally become references either.117 // labels must not accidentally become references either.
113 var refs []*ast.Text118 var refs []*ast.Text
@@ -196,7 +201,9 @@ type linkPolicy struct {
196 // idPrefix is the prefix of the heading ids that #fragment links point at.201 // idPrefix is the prefix of the heading ids that #fragment links point at.
197 idPrefix string202 idPrefix string
198 // untrusted is the policy of text from an anonymous visitor.203 // untrusted is the policy of text from an anonymous visitor.
199- untrusted bool204+ untrusted bool
205+ videoHosts []string
206+ rawVideo bool
200 }207 }
201 208
202 const (209 const (
@@ -233,6 +240,9 @@ func (p linkPolicy) apply(doc ast.Node) {
233 }240 }
234 for _, n := range links {241 for _, n := range links {
235 l := n.(*ast.Link)242 l := n.(*ast.Link)
243+ if p.standaloneVideo(l, string(l.Destination), plainText(l, p.source)) {
244+ continue
245+ }
236 dest, kind := p.target(string(l.Destination))246 dest, kind := p.target(string(l.Destination))
237 if kind == dropped {247 if kind == dropped {
238 unwrap(l)248 unwrap(l)
@@ -246,6 +256,9 @@ func (p linkPolicy) apply(doc ast.Node) {
246 }256 }
247 for _, n := range autos {257 for _, n := range autos {
248 a := n.(*ast.AutoLink)258 a := n.(*ast.AutoLink)
259+ if p.standaloneVideo(a, string(a.URL(p.source)), string(a.Label(p.source))) {
260+ continue
261+ }
249 if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {262 if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {
250 a.SetAttributeString("rel", p.rel())263 a.SetAttributeString("rel", p.rel())
251 continue264 continue
@@ -268,12 +281,16 @@ func unwrap(n ast.Node) {
268 parent.RemoveChild(parent, n)281 parent.RemoveChild(parent, n)
269 }282 }
270 283
271-// image replaces an image by its alt text.284+// image replaces a video image with a player, otherwise its alt text.
272 func (p linkPolicy) image(img *ast.Image) {285 func (p linkPolicy) image(img *ast.Image) {
273 alt := plainText(img, p.source)286 alt := plainText(img, p.source)
274 if alt == "" {287 if alt == "" {
275 alt = "image"288 alt = "image"
276 }289 }
290+ if src, ok := p.videoTarget(string(img.Destination), true); ok {
291+ img.Parent().ReplaceChild(img.Parent(), img, &videoNode{src: src, alt: alt})
292+ return
293+ }
277 img.Parent().ReplaceChild(img.Parent(), img, &altSpan{alt: alt})294 img.Parent().ReplaceChild(img.Parent(), img, &altSpan{alt: alt})
278 }295 }
279 296
@@ -333,7 +350,11 @@ func (p linkPolicy) target(dest string) (string, linkKind) {
333 rel = strings.TrimPrefix(rel, "/")350 rel = strings.TrimPrefix(rel, "/")
334 href := repoHref(p.repo)351 href := repoHref(p.repo)
335 if rel != "" {352 if rel != "" {
336- href = repoHref(p.repo, "blob", p.ref, rel)353+ route := "blob"
354+ if p.rawVideo {
355+ route = "raw"
356+ }
357+ href = repoHref(p.repo, route, p.ref, rel)
337 }358 }
338 if u.Fragment != "" {359 if u.Fragment != "" {
339 href += "#" + url.PathEscape(u.Fragment)360 href += "#" + url.PathEscape(u.Fragment)
@@ -400,6 +421,17 @@ func (n *altSpan) Dump(source []byte, level int) { ast.DumpHelper(n, source, lev
400 type altRenderer struct{}421 type altRenderer struct{}
401 422
402 func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {423 func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
424+ r.Register(kindVideo, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
425+ if entering {
426+ v := n.(*videoNode)
427+ _, _ = w.WriteString(`<video controls preload="metadata" src="`)
428+ _, _ = w.Write(util.EscapeHTML([]byte(v.src)))
429+ _, _ = w.WriteString(`" aria-label="`)
430+ _, _ = w.Write(util.EscapeHTML([]byte(v.alt)))
431+ _, _ = w.WriteString(`"></video>`)
432+ }
433+ return ast.WalkSkipChildren, nil
434+ })
403 r.Register(kindAlt, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {435 r.Register(kindAlt, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
404 if entering {436 if entering {
405 _, _ = w.WriteString(`<span class="alt">[`)437 _, _ = w.WriteString(`<span class="alt">[`)
@@ -488,3 +520,75 @@ func references(t *ast.Text, source []byte, res refResolver) {
488 parent.InsertBefore(parent, t, tail)520 parent.InsertBefore(parent, t, tail)
489 parent.RemoveChild(parent, t)521 parent.RemoveChild(parent, t)
490 }522 }
523+
524+// videoTarget uses the ordinary destination policy before restricting media origins.
525+func (p linkPolicy) videoTarget(dest string, requireExtension bool) (string, bool) {
526+ if p.untrusted {
527+ return "", false
528+ }
529+ u, err := url.Parse(strings.TrimSpace(dest))
530+ if err != nil {
531+ return "", false
532+ }
533+ if requireExtension && !videoExtension(u.Path) {
534+ return "", false
535+ }
536+ p.rawVideo = true
537+ target, kind := p.target(dest)
538+ if kind == dropped || strings.HasPrefix(target, "#") {
539+ return "", false
540+ }
541+ if kind == external && !mediapolicy.Allows(u, p.videoHosts) {
542+ return "", false
543+ }
544+ return target, true
545+}
546+
547+func (p linkPolicy) standaloneVideo(n ast.Node, dest, label string) bool {
548+ parent := n.Parent()
549+ if parent == nil || parent.Kind() != ast.KindParagraph || parent.ChildCount() != 1 {
550+ return false
551+ }
552+ u, err := url.Parse(dest)
553+ if err != nil || !mediapolicy.Allows(u, p.videoHosts) {
554+ return false
555+ }
556+ if !videoExtension(u.Path) && !githubAttachment(u) {
557+ return false
558+ }
559+ src, ok := p.videoTarget(dest, false)
560+ if !ok {
561+ return false
562+ }
563+ parent.ReplaceChild(parent, n, &videoNode{src: src, alt: label})
564+ return true
565+}
566+
567+var kindVideo = ast.NewNodeKind("Video")
568+
569+type videoNode struct {
570+ ast.BaseInline
571+ src, alt string
572+}
573+
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) }
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 +55 -0
@@ -383,3 +383,58 @@ func TestMarkdownHighlightDocumentBudget(t *testing.T) {
383383 assert.LessOrEqual(t, strings.Count(h, `<pre class="chroma">`), 4)
384384 assert.Contains(t, h, "<pre><code")
385385 }
386+
387+func TestMarkdownVideos(t *testing.T) {
388+ for _, tc := range []struct {
389+ name, source, want string
390+ video bool
391+ }{
392+ {"relative", `![demo](clips/demo.mp4)`, `src="/demo/raw/main/docs/clips/demo.mp4" aria-label="demo"`, true},
393+ {"root mov", `![demo](/clip.MOV)`, `src="/demo/raw/main/clip.MOV"`, true},
394+ {"webm", `![demo](clip.webm)`, `src="/demo/raw/main/docs/clip.webm"`, true},
395+ {"m4v", `![demo](clip.m4v)`, `src="/demo/raw/main/docs/clip.m4v"`, true},
396+ {"ogv", `![demo](clip.ogv)`, `src="/demo/raw/main/docs/clip.ogv"`, true},
397+ {"external image", `![demo](https://github.com/demo.mp4)`, `src="https://github.com/demo.mp4"`, 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},
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},
411+ {"inline link", `see [clip](https://github.com/user-attachments/assets/uuid) here`, `<a href=`, false},
412+ {"blocked image", `![demo](https://evil.example/demo.mp4)`, `<span class="alt">[demo]</span>`, false},
413+ {"blocked link", `https://evil.example/demo.mp4`, `<a href=`, false},
414+ {"host suffix", `![demo](https://github.com.evil.example/demo.mp4)`, `<span class="alt">`, false},
415+ {"credentials", `![demo](https://evil@github.com/demo.mp4)`, `<span class="alt">`, false},
416+ {"protocol relative", `![demo](//github.com/demo.mp4)`, `<span class="alt">`, false},
417+ {"javascript", `![demo](javascript:demo.mp4)`, `<span class="alt">`, false},
418+ {"ordinary image", `![demo](demo.png)`, `<span class="alt">`, false},
419+ {"escaped alt", `![a " & \<b\>](clip.mp4)`, `aria-label="a &quot; &amp; \&lt;b\&gt;"`, true},
420+ {"escaped path", `![demo](<a"&b.mp4>)`, `src="/demo/raw/main/docs/a%22&amp;b.mp4"`, true},
421+ } {
422+ t.Run(tc.name, func(t *testing.T) {
423+ h, _ := renderMD(t, tc.source)
424+ assert.Contains(t, h, tc.want)
425+ assert.Equal(t, tc.video, strings.Contains(h, `<video controls preload="metadata"`))
426+ untrusted, err := newMarkdown().renderUntrusted([]byte(tc.source), "u-", nil)
427+ require.NoError(t, err)
428+ assert.NotContains(t, string(untrusted), "<video")
429+ })
430+ }
431+ t.Run("configured host", func(t *testing.T) {
432+ m := newMarkdown("clips.example")
433+ h, _, err := m.render([]byte(`![demo](https://clips.example/a.mp4)`), "demo", "main", "")
434+ require.NoError(t, err)
435+ assert.Contains(t, string(h), "<video")
436+ h, _, err = m.render([]byte(`![demo](https://github.com/a.mp4)`), "demo", "main", "")
437+ require.NoError(t, err)
438+ assert.NotContains(t, string(h), "<video")
439+ })
440+}
@@ -383,3 +383,58 @@ func TestMarkdownHighlightDocumentBudget(t *testing.T) {
383 assert.LessOrEqual(t, strings.Count(h, `<pre class="chroma">`), 4)383 assert.LessOrEqual(t, strings.Count(h, `<pre class="chroma">`), 4)
384 assert.Contains(t, h, "<pre><code")384 assert.Contains(t, h, "<pre><code")
385 }385 }
386+
387+func TestMarkdownVideos(t *testing.T) {
388+ for _, tc := range []struct {
389+ name, source, want string
390+ video bool
391+ }{
392+ {"relative", `![demo](clips/demo.mp4)`, `src="/demo/raw/main/docs/clips/demo.mp4" aria-label="demo"`, true},
393+ {"root mov", `![demo](/clip.MOV)`, `src="/demo/raw/main/clip.MOV"`, true},
394+ {"webm", `![demo](clip.webm)`, `src="/demo/raw/main/docs/clip.webm"`, true},
395+ {"m4v", `![demo](clip.m4v)`, `src="/demo/raw/main/docs/clip.m4v"`, true},
396+ {"ogv", `![demo](clip.ogv)`, `src="/demo/raw/main/docs/clip.ogv"`, true},
397+ {"external image", `![demo](https://github.com/demo.mp4)`, `src="https://github.com/demo.mp4"`, 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},
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},
411+ {"inline link", `see [clip](https://github.com/user-attachments/assets/uuid) here`, `<a href=`, false},
412+ {"blocked image", `![demo](https://evil.example/demo.mp4)`, `<span class="alt">[demo]</span>`, false},
413+ {"blocked link", `https://evil.example/demo.mp4`, `<a href=`, false},
414+ {"host suffix", `![demo](https://github.com.evil.example/demo.mp4)`, `<span class="alt">`, false},
415+ {"credentials", `![demo](https://evil@github.com/demo.mp4)`, `<span class="alt">`, false},
416+ {"protocol relative", `![demo](//github.com/demo.mp4)`, `<span class="alt">`, false},
417+ {"javascript", `![demo](javascript:demo.mp4)`, `<span class="alt">`, false},
418+ {"ordinary image", `![demo](demo.png)`, `<span class="alt">`, false},
419+ {"escaped alt", `![a " & \<b\>](clip.mp4)`, `aria-label="a &quot; &amp; \&lt;b\&gt;"`, true},
420+ {"escaped path", `![demo](<a"&b.mp4>)`, `src="/demo/raw/main/docs/a%22&amp;b.mp4"`, true},
421+ } {
422+ t.Run(tc.name, func(t *testing.T) {
423+ h, _ := renderMD(t, tc.source)
424+ assert.Contains(t, h, tc.want)
425+ assert.Equal(t, tc.video, strings.Contains(h, `<video controls preload="metadata"`))
426+ untrusted, err := newMarkdown().renderUntrusted([]byte(tc.source), "u-", nil)
427+ require.NoError(t, err)
428+ assert.NotContains(t, string(untrusted), "<video")
429+ })
430+ }
431+ t.Run("configured host", func(t *testing.T) {
432+ m := newMarkdown("clips.example")
433+ h, _, err := m.render([]byte(`![demo](https://clips.example/a.mp4)`), "demo", "main", "")
434+ require.NoError(t, err)
435+ assert.Contains(t, string(h), "<video")
436+ h, _, err = m.render([]byte(`![demo](https://github.com/a.mp4)`), "demo", "main", "")
437+ require.NoError(t, err)
438+ assert.NotContains(t, string(h), "<video")
439+ })
440+}
M internal/adapters/web/raw.go +57 -25
@@ -3,8 +3,8 @@
33 package web
44
55 import (
6+ "bytes"
67 "context"
7- "errors"
88 "io"
99 "mime"
1010 "net/http"
@@ -15,6 +15,7 @@ import (
1515 "github.com/bnema/zerowrap"
1616
1717 "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/httprange"
1819 )
1920
2021 // rawTimeout bounds the stream of one file to the visitor.
@@ -28,7 +29,7 @@ const rawChunkSize = 64 << 10
2829 const rawCSP = "default-src 'none'; sandbox"
2930
3031 // raw streams the content of a file. Text is served as text/plain, the four
31-// image types (by their bytes, never SVG) as images, and anything else as an
32+// image types and videos (by their bytes, never SVG) inline, and anything else as an
3233 // attachment of unknown type. A file is never served as HTML or SVG, so what
3334 // a repository holds cannot run in the origin of the forge.
3435 func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
@@ -79,36 +80,41 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
7980 w.WriteHeader(http.StatusNotModified)
8081 return
8182 }
82- w.WriteHeader(http.StatusOK)
83- if r.Method == http.MethodHead {
83+
84+ // Git blob readers are forward-only. Seeking discards the prefix for each
85+ // request: bounded memory, but O(offset) I/O, intended for small video clips.
86+ // Reuse the single-range policy; multipart ranges are ignored, never buffered.
87+ h.Set("Accept-Ranges", "bytes")
88+ offset, length, partial, err := httprange.Select(r, b.info.Size, h.Get("ETag"))
89+ if err != nil {
90+ h.Set("Content-Range", httprange.Unsatisfied(b.info.Size))
91+ h.Set("Content-Length", "0")
92+ w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
8493 return
8594 }
86- if _, err := w.Write(b.buf); err != nil {
87- return
95+ status := http.StatusOK
96+ if partial {
97+ status = http.StatusPartialContent
98+ h.Set("Content-Range", httprange.ContentRange(offset, length, b.info.Size))
8899 }
89- b.buf = nil
90- if b.eof {
100+ h.Set("Content-Length", strconv.FormatInt(length, 10))
101+ w.WriteHeader(status)
102+ if r.Method == http.MethodHead {
91103 return
92104 }
93- chunk := make([]byte, rawChunkSize)
94- for {
95- n, rerr := b.body.Read(chunk)
96- if n > 0 {
97- if _, err := w.Write(chunk[:n]); err != nil {
98- return
99- }
100- }
101- if errors.Is(rerr, io.EOF) {
102- return
103- }
104- if rerr != nil {
105- // Headers are gone: all that is left is to cut the answer short,
106- // so that the client sees it is incomplete.
107- log := zerowrap.FromCtx(ctx)
108- log.Error().Err(rerr).Str("repo", repo.String()).Str("path", logPath(r.URL.Path)).Msg("raw stream failed")
109- panic(http.ErrAbortHandler)
105+ reader := io.MultiReader(bytes.NewReader(b.buf), b.body)
106+ if _, err = io.CopyN(io.Discard, reader, offset); err == nil {
107+ var written int64
108+ written, err = io.CopyBuffer(w, io.LimitReader(reader, length), make([]byte, rawChunkSize))
109+ if err == nil && written != length {
110+ err = io.ErrUnexpectedEOF
110111 }
111112 }
113+ if err != nil {
114+ log := zerowrap.FromCtx(ctx)
115+ log.Error().Err(err).Str("repo", repo.String()).Str("path", logPath(r.URL.Path)).Msg("raw stream failed")
116+ panic(http.ErrAbortHandler)
117+ }
112118 }
113119
114120 // rawTarget finds the file a raw URL designates and the name of its repository.
@@ -130,8 +136,34 @@ func rawType(b *blobStream) (typ string, attachment bool) {
130136 if img := sniffImage(b.buf); img != "" {
131137 return img, false
132138 }
139+ if video := sniffVideo(b.buf); video != "" {
140+ return video, false
141+ }
133142 if !b.info.Binary {
134143 return "text/plain; charset=utf-8", false
135144 }
136145 return "application/octet-stream", true
137146 }
147+
148+// sniffVideo admits known video signatures only, never HTML or SVG by extension.
149+func sniffVideo(head []byte) string {
150+ switch http.DetectContentType(head) {
151+ case "video/mp4":
152+ return "video/mp4"
153+ case "video/webm":
154+ return "video/webm"
155+ }
156+ if len(head) >= 12 && string(head[4:8]) == "ftyp" {
157+ switch string(head[8:12]) {
158+ case "qt ":
159+ return "video/quicktime"
160+ case "M4V ", "isom", "iso2", "mp41", "mp42", "avc1":
161+ return "video/mp4"
162+ }
163+ }
164+ // Ogg is a container for audio as well: require the Theora identification packet.
165+ if bytes.HasPrefix(head, []byte("OggS\x00")) && bytes.Contains(head, []byte("\x80theora")) {
166+ return "video/ogg"
167+ }
168+ return ""
169+}
@@ -3,8 +3,8 @@
3 package web3 package web
4 4
5 import (5 import (
6+ "bytes"
6 "context"7 "context"
7- "errors"
8 "io"8 "io"
9 "mime"9 "mime"
10 "net/http"10 "net/http"
@@ -15,6 +15,7 @@ import (
15 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
16 16
17 "bnema.dev/ermite/internal/domain"17 "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/httprange"
18 )19 )
19 20
20 // rawTimeout bounds the stream of one file to the visitor.21 // rawTimeout bounds the stream of one file to the visitor.
@@ -28,7 +29,7 @@ const rawChunkSize = 64 << 10
28 const rawCSP = "default-src 'none'; sandbox"29 const rawCSP = "default-src 'none'; sandbox"
29 30
30 // raw streams the content of a file. Text is served as text/plain, the four31 // raw streams the content of a file. Text is served as text/plain, the four
31-// image types (by their bytes, never SVG) as images, and anything else as an32+// image types and videos (by their bytes, never SVG) inline, and anything else as an
32 // attachment of unknown type. A file is never served as HTML or SVG, so what33 // attachment of unknown type. A file is never served as HTML or SVG, so what
33 // a repository holds cannot run in the origin of the forge.34 // a repository holds cannot run in the origin of the forge.
34 func (s *Server) raw(w http.ResponseWriter, r *http.Request) {35 func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
@@ -79,36 +80,41 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
79 w.WriteHeader(http.StatusNotModified)80 w.WriteHeader(http.StatusNotModified)
80 return81 return
81 }82 }
82- w.WriteHeader(http.StatusOK)83+
83- if r.Method == http.MethodHead {84+ // Git blob readers are forward-only. Seeking discards the prefix for each
85+ // request: bounded memory, but O(offset) I/O, intended for small video clips.
86+ // Reuse the single-range policy; multipart ranges are ignored, never buffered.
87+ h.Set("Accept-Ranges", "bytes")
88+ offset, length, partial, err := httprange.Select(r, b.info.Size, h.Get("ETag"))
89+ if err != nil {
90+ h.Set("Content-Range", httprange.Unsatisfied(b.info.Size))
91+ h.Set("Content-Length", "0")
92+ w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
84 return93 return
85 }94 }
86- if _, err := w.Write(b.buf); err != nil {95+ status := http.StatusOK
87- return96+ if partial {
97+ status = http.StatusPartialContent
98+ h.Set("Content-Range", httprange.ContentRange(offset, length, b.info.Size))
88 }99 }
89- b.buf = nil100+ h.Set("Content-Length", strconv.FormatInt(length, 10))
90- if b.eof {101+ w.WriteHeader(status)
102+ if r.Method == http.MethodHead {
91 return103 return
92 }104 }
93- chunk := make([]byte, rawChunkSize)105+ reader := io.MultiReader(bytes.NewReader(b.buf), b.body)
94- for {106+ if _, err = io.CopyN(io.Discard, reader, offset); err == nil {
95- n, rerr := b.body.Read(chunk)107+ var written int64
96- if n > 0 {108+ written, err = io.CopyBuffer(w, io.LimitReader(reader, length), make([]byte, rawChunkSize))
97- if _, err := w.Write(chunk[:n]); err != nil {109+ if err == nil && written != length {
98- return110+ err = io.ErrUnexpectedEOF
99- }
100- }
101- if errors.Is(rerr, io.EOF) {
102- return
103- }
104- if rerr != nil {
105- // Headers are gone: all that is left is to cut the answer short,
106- // so that the client sees it is incomplete.
107- log := zerowrap.FromCtx(ctx)
108- log.Error().Err(rerr).Str("repo", repo.String()).Str("path", logPath(r.URL.Path)).Msg("raw stream failed")
109- panic(http.ErrAbortHandler)
110 }111 }
111 }112 }
113+ if err != nil {
114+ log := zerowrap.FromCtx(ctx)
115+ log.Error().Err(err).Str("repo", repo.String()).Str("path", logPath(r.URL.Path)).Msg("raw stream failed")
116+ panic(http.ErrAbortHandler)
117+ }
112 }118 }
113 119
114 // rawTarget finds the file a raw URL designates and the name of its repository.120 // rawTarget finds the file a raw URL designates and the name of its repository.
@@ -130,8 +136,34 @@ func rawType(b *blobStream) (typ string, attachment bool) {
130 if img := sniffImage(b.buf); img != "" {136 if img := sniffImage(b.buf); img != "" {
131 return img, false137 return img, false
132 }138 }
139+ if video := sniffVideo(b.buf); video != "" {
140+ return video, false
141+ }
133 if !b.info.Binary {142 if !b.info.Binary {
134 return "text/plain; charset=utf-8", false143 return "text/plain; charset=utf-8", false
135 }144 }
136 return "application/octet-stream", true145 return "application/octet-stream", true
137 }146 }
147+
148+// sniffVideo admits known video signatures only, never HTML or SVG by extension.
149+func sniffVideo(head []byte) string {
150+ switch http.DetectContentType(head) {
151+ case "video/mp4":
152+ return "video/mp4"
153+ case "video/webm":
154+ return "video/webm"
155+ }
156+ if len(head) >= 12 && string(head[4:8]) == "ftyp" {
157+ switch string(head[8:12]) {
158+ case "qt ":
159+ return "video/quicktime"
160+ case "M4V ", "isom", "iso2", "mp41", "mp42", "avc1":
161+ return "video/mp4"
162+ }
163+ }
164+ // Ogg is a container for audio as well: require the Theora identification packet.
165+ if bytes.HasPrefix(head, []byte("OggS\x00")) && bytes.Contains(head, []byte("\x80theora")) {
166+ return "video/ogg"
167+ }
168+ return ""
169+}
M internal/adapters/web/raw_test.go +67 -0
@@ -96,3 +96,70 @@ func TestRawRevalidation(t *testing.T) {
9696 })
9797 }
9898 }
99+
100+func TestRawVideoTypes(t *testing.T) {
101+ for _, tc := range []struct {
102+ name, head, typ string
103+ attachment bool
104+ }{
105+ {"mp4", "\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom", "video/mp4", false},
106+ {"mov", "\x00\x00\x00\x14ftypqt \x00\x00\x00\x00qt ", "video/quicktime", false},
107+ {"m4v", "\x00\x00\x00\x14ftypM4V \x00\x00\x00\x00M4V ", "video/mp4", false},
108+ {"webm", "\x1a\x45\xdf\xa3\x87\x42\x82\x84webm", "video/webm", false},
109+ {"ogg", "OggS\x00header\x80theora", "video/ogg", false},
110+ {"ogg audio", "OggS\x00headerOpusHead", "application/octet-stream", true},
111+ {"html", "<html><script>alert(1)</script>", "application/octet-stream", true},
112+ {"svg", `<svg onload="alert(1)">`, "application/octet-stream", true},
113+ {"unknown", "\x00unknown", "application/octet-stream", true},
114+ } {
115+ t.Run(tc.name, func(t *testing.T) {
116+ typ, attachment := rawType(&blobStream{buf: []byte(tc.head), info: domain.BlobInfo{Binary: true}})
117+ assert.Equal(t, tc.typ, typ)
118+ assert.Equal(t, tc.attachment, attachment)
119+ })
120+ }
121+}
122+
123+func TestRawVideoRanges(t *testing.T) {
124+ body := "\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom" + strings.Repeat("0123456789", 100)
125+ for _, tc := range []struct {
126+ name, method, rng, ifRange string
127+ status int
128+ start, end int
129+ cr string
130+ }{
131+ {"whole", "GET", "", "", 200, 0, len(body), ""},
132+ {"prefix", "GET", "bytes=2-4", "", 206, 2, 5, "bytes 2-4/1024"},
133+ {"after sniff buffer", "GET", "bytes=700-709", "", 206, 700, 710, "bytes 700-709/1024"},
134+ {"suffix", "GET", "bytes=-4", "", 206, 1020, 1024, "bytes 1020-1023/1024"},
135+ {"open", "GET", "bytes=1020-", "", 206, 1020, 1024, "bytes 1020-1023/1024"},
136+ {"clipped end", "GET", "bytes=1020-9999", "", 206, 1020, 1024, "bytes 1020-1023/1024"},
137+ {"past size", "GET", "bytes=1024-", "", 416, 0, 0, "bytes */1024"},
138+ {"invalid", "GET", "bytes=a-b", "", 416, 0, 0, "bytes */1024"},
139+ {"multiple ignored", "GET", "bytes=0-1,4-5", "", 200, 0, len(body), ""},
140+ {"stale if-range", "GET", "bytes=2-4", `"old"`, 200, 0, len(body), ""},
141+ {"matched if-range", "GET", "bytes=2-4", `"` + blobID + `"`, 206, 2, 5, "bytes 2-4/1024"},
142+ {"head", "HEAD", "bytes=2-4", "", 200, 0, 0, ""},
143+ } {
144+ t.Run(tc.name, func(t *testing.T) {
145+ browse := mocks.NewBrowseService(t)
146+ browse.EXPECT().OpenBlob(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything, mock.Anything).Return(
147+ domain.BlobInfo{ID: objectID(t, blobID), Path: "clip.mp4", Size: int64(len(body)), Binary: true}, io.NopCloser(strings.NewReader(body)), nil).Once()
148+ browse.EXPECT().ListRefs(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(ports.RefPage{Refs: []domain.Ref{{Name: "refs/heads/main", ShortName: "main", Kind: domain.RefKindBranch, ID: objectID(t, mainTip), CommitID: objectID(t, mainTip)}}}, nil).Maybe()
149+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
150+ req := httptest.NewRequest(tc.method, "/r/raw/main/clip.mp4", nil)
151+ req.Header.Set("Range", tc.rng)
152+ req.Header.Set("If-Range", tc.ifRange)
153+ rec := httptest.NewRecorder()
154+ srv.ServeHTTP(rec, req)
155+ assert.Equal(t, tc.status, rec.Code)
156+ assert.Equal(t, body[tc.start:tc.end], rec.Body.String())
157+ assert.Equal(t, tc.cr, rec.Header().Get("Content-Range"))
158+ assert.Equal(t, "bytes", rec.Header().Get("Accept-Ranges"))
159+ assert.Equal(t, "video/mp4", rec.Header().Get("Content-Type"))
160+ assert.Empty(t, rec.Header().Get("Content-Disposition"))
161+ assert.Equal(t, "nosniff", rec.Header().Get("X-Content-Type-Options"))
162+ assert.Equal(t, rawCSP, rec.Header().Get("Content-Security-Policy"))
163+ })
164+ }
165+}
@@ -96,3 +96,70 @@ func TestRawRevalidation(t *testing.T) {
96 })96 })
97 }97 }
98 }98 }
99+
100+func TestRawVideoTypes(t *testing.T) {
101+ for _, tc := range []struct {
102+ name, head, typ string
103+ attachment bool
104+ }{
105+ {"mp4", "\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom", "video/mp4", false},
106+ {"mov", "\x00\x00\x00\x14ftypqt \x00\x00\x00\x00qt ", "video/quicktime", false},
107+ {"m4v", "\x00\x00\x00\x14ftypM4V \x00\x00\x00\x00M4V ", "video/mp4", false},
108+ {"webm", "\x1a\x45\xdf\xa3\x87\x42\x82\x84webm", "video/webm", false},
109+ {"ogg", "OggS\x00header\x80theora", "video/ogg", false},
110+ {"ogg audio", "OggS\x00headerOpusHead", "application/octet-stream", true},
111+ {"html", "<html><script>alert(1)</script>", "application/octet-stream", true},
112+ {"svg", `<svg onload="alert(1)">`, "application/octet-stream", true},
113+ {"unknown", "\x00unknown", "application/octet-stream", true},
114+ } {
115+ t.Run(tc.name, func(t *testing.T) {
116+ typ, attachment := rawType(&blobStream{buf: []byte(tc.head), info: domain.BlobInfo{Binary: true}})
117+ assert.Equal(t, tc.typ, typ)
118+ assert.Equal(t, tc.attachment, attachment)
119+ })
120+ }
121+}
122+
123+func TestRawVideoRanges(t *testing.T) {
124+ body := "\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom" + strings.Repeat("0123456789", 100)
125+ for _, tc := range []struct {
126+ name, method, rng, ifRange string
127+ status int
128+ start, end int
129+ cr string
130+ }{
131+ {"whole", "GET", "", "", 200, 0, len(body), ""},
132+ {"prefix", "GET", "bytes=2-4", "", 206, 2, 5, "bytes 2-4/1024"},
133+ {"after sniff buffer", "GET", "bytes=700-709", "", 206, 700, 710, "bytes 700-709/1024"},
134+ {"suffix", "GET", "bytes=-4", "", 206, 1020, 1024, "bytes 1020-1023/1024"},
135+ {"open", "GET", "bytes=1020-", "", 206, 1020, 1024, "bytes 1020-1023/1024"},
136+ {"clipped end", "GET", "bytes=1020-9999", "", 206, 1020, 1024, "bytes 1020-1023/1024"},
137+ {"past size", "GET", "bytes=1024-", "", 416, 0, 0, "bytes */1024"},
138+ {"invalid", "GET", "bytes=a-b", "", 416, 0, 0, "bytes */1024"},
139+ {"multiple ignored", "GET", "bytes=0-1,4-5", "", 200, 0, len(body), ""},
140+ {"stale if-range", "GET", "bytes=2-4", `"old"`, 200, 0, len(body), ""},
141+ {"matched if-range", "GET", "bytes=2-4", `"` + blobID + `"`, 206, 2, 5, "bytes 2-4/1024"},
142+ {"head", "HEAD", "bytes=2-4", "", 200, 0, 0, ""},
143+ } {
144+ t.Run(tc.name, func(t *testing.T) {
145+ browse := mocks.NewBrowseService(t)
146+ browse.EXPECT().OpenBlob(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything, mock.Anything).Return(
147+ domain.BlobInfo{ID: objectID(t, blobID), Path: "clip.mp4", Size: int64(len(body)), Binary: true}, io.NopCloser(strings.NewReader(body)), nil).Once()
148+ browse.EXPECT().ListRefs(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(ports.RefPage{Refs: []domain.Ref{{Name: "refs/heads/main", ShortName: "main", Kind: domain.RefKindBranch, ID: objectID(t, mainTip), CommitID: objectID(t, mainTip)}}}, nil).Maybe()
149+ srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse})
150+ req := httptest.NewRequest(tc.method, "/r/raw/main/clip.mp4", nil)
151+ req.Header.Set("Range", tc.rng)
152+ req.Header.Set("If-Range", tc.ifRange)
153+ rec := httptest.NewRecorder()
154+ srv.ServeHTTP(rec, req)
155+ assert.Equal(t, tc.status, rec.Code)
156+ assert.Equal(t, body[tc.start:tc.end], rec.Body.String())
157+ assert.Equal(t, tc.cr, rec.Header().Get("Content-Range"))
158+ assert.Equal(t, "bytes", rec.Header().Get("Accept-Ranges"))
159+ assert.Equal(t, "video/mp4", rec.Header().Get("Content-Type"))
160+ assert.Empty(t, rec.Header().Get("Content-Disposition"))
161+ assert.Equal(t, "nosniff", rec.Header().Get("X-Content-Type-Options"))
162+ assert.Equal(t, rawCSP, rec.Header().Get("Content-Security-Policy"))
163+ })
164+ }
165+}
M internal/adapters/web/static/css/40-summary.css +1 -0
@@ -55,5 +55,6 @@
5555 & hr { margin: var(--s5) 0; }
5656 & .alt { color: var(--ink-mute); }
5757 & img { display: none; }
58+ & video { display: block; max-width: 100%; margin-inline: auto; }
5859 }
5960 }
@@ -55,5 +55,6 @@
55 & hr { margin: var(--s5) 0; }55 & hr { margin: var(--s5) 0; }
56 & .alt { color: var(--ink-mute); }56 & .alt { color: var(--ink-mute); }
57 & img { display: none; }57 & img { display: none; }
58+ & video { display: block; max-width: 100%; margin-inline: auto; }
58 }59 }
59 }60 }
M internal/adapters/web/web.go +3 -1
@@ -47,6 +47,8 @@ const requestTimeout = 30 * time.Second
4747
4848 // Config configures a Server.
4949 type Config struct {
50+ // VideoHosts are exact external video hosts; nil uses the defaults.
51+ VideoHosts []string
5052 // Mirrors answers the push mirror badge shown to repository admins. Without it there is
5153 // no badge. Leave it unset rather than assigning a typed nil pointer.
5254 Mirrors ports.MirrorWarner
@@ -174,7 +176,7 @@ func New(cfg Config) (*Server, error) {
174176 return nil, errors.New("web: sessions need a failure limiter")
175177 }
176178 s := &Server{
177- cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),
179+ cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(cfg.VideoHosts...),
178180 pathCache: newPathCache(pathCacheBytes, pathBuilds),
179181 licenses: newPathCache(1<<20, homeWorkers),
180182 hl: newHLCache(hlCacheBytes),
@@ -47,6 +47,8 @@ const requestTimeout = 30 * time.Second
47 47
48 // Config configures a Server.48 // Config configures a Server.
49 type Config struct {49 type Config struct {
50+ // VideoHosts are exact external video hosts; nil uses the defaults.
51+ VideoHosts []string
50 // Mirrors answers the push mirror badge shown to repository admins. Without it there is52 // Mirrors answers the push mirror badge shown to repository admins. Without it there is
51 // no badge. Leave it unset rather than assigning a typed nil pointer.53 // no badge. Leave it unset rather than assigning a typed nil pointer.
52 Mirrors ports.MirrorWarner54 Mirrors ports.MirrorWarner
@@ -174,7 +176,7 @@ func New(cfg Config) (*Server, error) {
174 return nil, errors.New("web: sessions need a failure limiter")176 return nil, errors.New("web: sessions need a failure limiter")
175 }177 }
176 s := &Server{178 s := &Server{
177- cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(),179+ cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(cfg.VideoHosts...),
178 pathCache: newPathCache(pathCacheBytes, pathBuilds),180 pathCache: newPathCache(pathCacheBytes, pathBuilds),
179 licenses: newPathCache(1<<20, homeWorkers),181 licenses: newPathCache(1<<20, homeWorkers),
180 hl: newHLCache(hlCacheBytes),182 hl: newHLCache(hlCacheBytes),
M internal/app/forge.go +1 -0
@@ -521,6 +521,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
521521 f.server = httpserver.New(httpserver.Config{
522522 Addr: cfg.Addr,
523523 PublicURL: cfg.PublicURL,
524+ VideoHosts: cfg.VideoHosts,
524525 HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)},
525526 Routes: routes,
526527 API: f.API.Handler(),
@@ -521,6 +521,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
521 f.server = httpserver.New(httpserver.Config{521 f.server = httpserver.New(httpserver.Config{
522 Addr: cfg.Addr,522 Addr: cfg.Addr,
523 PublicURL: cfg.PublicURL,523 PublicURL: cfg.PublicURL,
524+ VideoHosts: cfg.VideoHosts,
524 HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)},525 HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)},
525 Routes: routes,526 Routes: routes,
526 API: f.API.Handler(),527 API: f.API.Handler(),
M internal/app/serve.go +1 -1
@@ -24,7 +24,7 @@ func newServeCmd() *cobra.Command {
2424 Long: "Run the server. It is configured by the environment only:\n" +
2525 config.EnvAddr + ", " + config.EnvDataDir + ", " + config.EnvPublicURL + ", " + config.EnvAdminToken + ",\n" +
2626 config.EnvTrustedProxies + ", " + config.EnvGit + ", " + config.EnvSourceURL + ", " +
27- config.EnvLogLevel + ", " + config.EnvLogFormat + ",\n" +
27+ config.EnvVideoHosts + ", " + config.EnvLogLevel + ", " + config.EnvLogFormat + ",\n" +
2828 config.EnvCommitterName + ", " + config.EnvCommitterEmail + ".",
2929 Args: cobra.NoArgs,
3030 RunE: func(cmd *cobra.Command, _ []string) error {
@@ -24,7 +24,7 @@ func newServeCmd() *cobra.Command {
24 Long: "Run the server. It is configured by the environment only:\n" +24 Long: "Run the server. It is configured by the environment only:\n" +
25 config.EnvAddr + ", " + config.EnvDataDir + ", " + config.EnvPublicURL + ", " + config.EnvAdminToken + ",\n" +25 config.EnvAddr + ", " + config.EnvDataDir + ", " + config.EnvPublicURL + ", " + config.EnvAdminToken + ",\n" +
26 config.EnvTrustedProxies + ", " + config.EnvGit + ", " + config.EnvSourceURL + ", " +26 config.EnvTrustedProxies + ", " + config.EnvGit + ", " + config.EnvSourceURL + ", " +
27- config.EnvLogLevel + ", " + config.EnvLogFormat + ",\n" +27+ config.EnvVideoHosts + ", " + config.EnvLogLevel + ", " + config.EnvLogFormat + ",\n" +
28 config.EnvCommitterName + ", " + config.EnvCommitterEmail + ".",28 config.EnvCommitterName + ", " + config.EnvCommitterEmail + ".",
29 Args: cobra.NoArgs,29 Args: cobra.NoArgs,
30 RunE: func(cmd *cobra.Command, _ []string) error {30 RunE: func(cmd *cobra.Command, _ []string) error {
M internal/app/web.go +1 -0
@@ -62,6 +62,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
6262 Mirrors: f.Mirrors,
6363 Owner: cfg.Owner,
6464 PublicURL: cfg.PublicURL,
65+ VideoHosts: cfg.VideoHosts,
6566 SourceURL: cfg.SourceURL,
6667 Version: serverVersion(),
6768
@@ -62,6 +62,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
62 Mirrors: f.Mirrors,62 Mirrors: f.Mirrors,
63 Owner: cfg.Owner,63 Owner: cfg.Owner,
64 PublicURL: cfg.PublicURL,64 PublicURL: cfg.PublicURL,
65+ VideoHosts: cfg.VideoHosts,
65 SourceURL: cfg.SourceURL,66 SourceURL: cfg.SourceURL,
66 Version: serverVersion(),67 Version: serverVersion(),
67 68
M internal/app/web_files_test.go +1 -1
@@ -465,7 +465,7 @@ func TestWebFilesContentSecurity(t *testing.T) {
465465 h := newHarness(t, harnessOpts{})
466466 h.webFilesRepo("files")
467467
468- const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'"
468+ const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'; media-src 'self' https://github.com http://github.com https://user-images.githubusercontent.com http://user-images.githubusercontent.com https://private-user-images.githubusercontent.com http://private-user-images.githubusercontent.com https://github-production-user-asset-6210df.s3.amazonaws.com http://github-production-user-asset-6210df.s3.amazonaws.com"
469469 for _, p := range []string{
470470 "/files/tree/main", "/files/tree/main/docs", "/files/blob/main/main.go", "/files/blob/main/docs/guide.md",
471471 "/files/blob/main/pic.png", "/files/blob/main/logo.svg", "/files/blob/main/evil.go", "/files/blob/main/big.txt",
@@ -465,7 +465,7 @@ func TestWebFilesContentSecurity(t *testing.T) {
465 h := newHarness(t, harnessOpts{})465 h := newHarness(t, harnessOpts{})
466 h.webFilesRepo("files")466 h.webFilesRepo("files")
467 467
468- const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'"468+ const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'; media-src 'self' https://github.com http://github.com https://user-images.githubusercontent.com http://user-images.githubusercontent.com https://private-user-images.githubusercontent.com http://private-user-images.githubusercontent.com https://github-production-user-asset-6210df.s3.amazonaws.com http://github-production-user-asset-6210df.s3.amazonaws.com"
469 for _, p := range []string{469 for _, p := range []string{
470 "/files/tree/main", "/files/tree/main/docs", "/files/blob/main/main.go", "/files/blob/main/docs/guide.md",470 "/files/tree/main", "/files/tree/main/docs", "/files/blob/main/main.go", "/files/blob/main/docs/guide.md",
471 "/files/blob/main/pic.png", "/files/blob/main/logo.svg", "/files/blob/main/evil.go", "/files/blob/main/big.txt",471 "/files/blob/main/pic.png", "/files/blob/main/logo.svg", "/files/blob/main/evil.go", "/files/blob/main/big.txt",
M internal/app/web_test.go +2 -2
@@ -203,14 +203,14 @@ func assertNoInline(t *testing.T, body string) {
203203 }
204204
205205 // AC6: the CSP forbids inline scripts and styles, nothing is loaded from
206-// another origin, no page has an inline script or a style attribute, and
206+// another origin except allowlisted media, no page has inline scripts or styles, and
207207 // every page carries the security headers.
208208 func TestWebContentSecurity(t *testing.T) {
209209 h := newHarness(t, harnessOpts{})
210210 h.webRepo("demo")
211211 h.seed("quiet", domain.VisibilityPublic)
212212
213- const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'"
213+ const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'; media-src 'self' https://github.com http://github.com https://user-images.githubusercontent.com http://user-images.githubusercontent.com https://private-user-images.githubusercontent.com http://private-user-images.githubusercontent.com https://github-production-user-asset-6210df.s3.amazonaws.com http://github-production-user-asset-6210df.s3.amazonaws.com"
214214 for _, p := range []string{"/", "/demo", "/demo/refs", "/quiet", "/nope", "/demo/commits/main"} {
215215 status, hdr, body := h.get(p)
216216 assert.Contains(t, []int{200, 404}, status, p)
@@ -203,14 +203,14 @@ func assertNoInline(t *testing.T, body string) {
203 }203 }
204 204
205 // AC6: the CSP forbids inline scripts and styles, nothing is loaded from205 // AC6: the CSP forbids inline scripts and styles, nothing is loaded from
206-// another origin, no page has an inline script or a style attribute, and206+// another origin except allowlisted media, no page has inline scripts or styles, and
207 // every page carries the security headers.207 // every page carries the security headers.
208 func TestWebContentSecurity(t *testing.T) {208 func TestWebContentSecurity(t *testing.T) {
209 h := newHarness(t, harnessOpts{})209 h := newHarness(t, harnessOpts{})
210 h.webRepo("demo")210 h.webRepo("demo")
211 h.seed("quiet", domain.VisibilityPublic)211 h.seed("quiet", domain.VisibilityPublic)
212 212
213- const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'"213+ const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'; media-src 'self' https://github.com http://github.com https://user-images.githubusercontent.com http://user-images.githubusercontent.com https://private-user-images.githubusercontent.com http://private-user-images.githubusercontent.com https://github-production-user-asset-6210df.s3.amazonaws.com http://github-production-user-asset-6210df.s3.amazonaws.com"
214 for _, p := range []string{"/", "/demo", "/demo/refs", "/quiet", "/nope", "/demo/commits/main"} {214 for _, p := range []string{"/", "/demo", "/demo/refs", "/quiet", "/nope", "/demo/commits/main"} {
215 status, hdr, body := h.get(p)215 status, hdr, body := h.get(p)
216 assert.Contains(t, []int{200, 404}, status, p)216 assert.Contains(t, []int{200, 404}, status, p)
M internal/boundary/boundary_test.go +9 -3
@@ -36,6 +36,7 @@ const (
3636 layerAdapter packageLayer = "adapter"
3737 layerClientLimit packageLayer = "clientlimit"
3838 layerHTTPRange packageLayer = "httprange"
39+ layerMediaPolicy packageLayer = "mediapolicy"
3940 layerConfig packageLayer = "config"
4041 layerPorts packageLayer = "ports"
4142 layerDomain packageLayer = "domain"
@@ -58,15 +59,18 @@ const (
5859 // - internal/clientlimit is infrastructure shared by the HTTP adapters.
5960 // - internal/httprange is infrastructure shared by the HTTP adapters that
6061 // serve byte ranges; it imports nothing of the module.
62+// - internal/mediapolicy validates hostnames and shares the video origin policy;
63+// it imports nothing of the module.
6164 // - internal/config parses the environment into plain values.
6265 var productionDependencies = map[packageLayer]map[packageLayer]bool{
6366 layerCommand: {layerApp: true},
64- layerApp: {layerUsecase: true, layerAdapter: true, layerClientLimit: true, layerHTTPRange: true, layerConfig: true, layerPorts: true, layerDomain: true, layerGenerated: true},
67+ layerApp: {layerUsecase: true, layerAdapter: true, layerClientLimit: true, layerHTTPRange: true, layerMediaPolicy: true, layerConfig: true, layerPorts: true, layerDomain: true, layerGenerated: true},
6568 layerUsecase: {layerUsecase: true, layerPorts: true, layerDomain: true},
66- layerAdapter: {layerAdapter: true, layerClientLimit: true, layerHTTPRange: true, layerPorts: true, layerDomain: true, layerGenerated: true},
69+ layerAdapter: {layerAdapter: true, layerClientLimit: true, layerHTTPRange: true, layerMediaPolicy: true, layerPorts: true, layerDomain: true, layerGenerated: true},
6770 layerClientLimit: {layerPorts: true},
6871 layerHTTPRange: {},
69- layerConfig: {},
72+ layerConfig: {layerMediaPolicy: true},
73+ layerMediaPolicy: {},
7074 layerPorts: {layerDomain: true},
7175 layerDomain: {layerDomain: true},
7276 layerGenerated: {layerGenerated: true},
@@ -331,6 +335,8 @@ func classifyPackage(path string) (packageLayer, error) {
331335 return layerClientLimit, nil
332336 case under(path, p("internal/httprange")):
333337 return layerHTTPRange, nil
338+ case under(path, p("internal/mediapolicy")):
339+ return layerMediaPolicy, nil
334340 case under(path, p("internal/config")):
335341 return layerConfig, nil
336342 case path == p("internal/ports"):
@@ -36,6 +36,7 @@ const (
36 layerAdapter packageLayer = "adapter"36 layerAdapter packageLayer = "adapter"
37 layerClientLimit packageLayer = "clientlimit"37 layerClientLimit packageLayer = "clientlimit"
38 layerHTTPRange packageLayer = "httprange"38 layerHTTPRange packageLayer = "httprange"
39+ layerMediaPolicy packageLayer = "mediapolicy"
39 layerConfig packageLayer = "config"40 layerConfig packageLayer = "config"
40 layerPorts packageLayer = "ports"41 layerPorts packageLayer = "ports"
41 layerDomain packageLayer = "domain"42 layerDomain packageLayer = "domain"
@@ -58,15 +59,18 @@ const (
58 // - internal/clientlimit is infrastructure shared by the HTTP adapters.59 // - internal/clientlimit is infrastructure shared by the HTTP adapters.
59 // - internal/httprange is infrastructure shared by the HTTP adapters that60 // - internal/httprange is infrastructure shared by the HTTP adapters that
60 // serve byte ranges; it imports nothing of the module.61 // serve byte ranges; it imports nothing of the module.
62+// - internal/mediapolicy validates hostnames and shares the video origin policy;
63+// it imports nothing of the module.
61 // - internal/config parses the environment into plain values.64 // - internal/config parses the environment into plain values.
62 var productionDependencies = map[packageLayer]map[packageLayer]bool{65 var productionDependencies = map[packageLayer]map[packageLayer]bool{
63 layerCommand: {layerApp: true},66 layerCommand: {layerApp: true},
64- layerApp: {layerUsecase: true, layerAdapter: true, layerClientLimit: true, layerHTTPRange: true, layerConfig: true, layerPorts: true, layerDomain: true, layerGenerated: true},67+ layerApp: {layerUsecase: true, layerAdapter: true, layerClientLimit: true, layerHTTPRange: true, layerMediaPolicy: true, layerConfig: true, layerPorts: true, layerDomain: true, layerGenerated: true},
65 layerUsecase: {layerUsecase: true, layerPorts: true, layerDomain: true},68 layerUsecase: {layerUsecase: true, layerPorts: true, layerDomain: true},
66- layerAdapter: {layerAdapter: true, layerClientLimit: true, layerHTTPRange: true, layerPorts: true, layerDomain: true, layerGenerated: true},69+ layerAdapter: {layerAdapter: true, layerClientLimit: true, layerHTTPRange: true, layerMediaPolicy: true, layerPorts: true, layerDomain: true, layerGenerated: true},
67 layerClientLimit: {layerPorts: true},70 layerClientLimit: {layerPorts: true},
68 layerHTTPRange: {},71 layerHTTPRange: {},
69- layerConfig: {},72+ layerConfig: {layerMediaPolicy: true},
73+ layerMediaPolicy: {},
70 layerPorts: {layerDomain: true},74 layerPorts: {layerDomain: true},
71 layerDomain: {layerDomain: true},75 layerDomain: {layerDomain: true},
72 layerGenerated: {layerGenerated: true},76 layerGenerated: {layerGenerated: true},
@@ -331,6 +335,8 @@ func classifyPackage(path string) (packageLayer, error) {
331 return layerClientLimit, nil335 return layerClientLimit, nil
332 case under(path, p("internal/httprange")):336 case under(path, p("internal/httprange")):
333 return layerHTTPRange, nil337 return layerHTTPRange, nil
338+ case under(path, p("internal/mediapolicy")):
339+ return layerMediaPolicy, nil
334 case under(path, p("internal/config")):340 case under(path, p("internal/config")):
335 return layerConfig, nil341 return layerConfig, nil
336 case path == p("internal/ports"):342 case path == p("internal/ports"):
M internal/config/config.go +10 -0
@@ -18,10 +18,13 @@ import (
1818 "strings"
1919 "time"
2020 "unicode/utf8"
21+
22+ "bnema.dev/ermite/internal/mediapolicy"
2123 )
2224
2325 // Variable names.
2426 const (
27+ EnvVideoHosts = "ERMITE_VIDEO_HOSTS"
2528 EnvAddr = "ERMITE_ADDR"
2629 EnvDataDir = "ERMITE_DATA_DIR"
2730 EnvPublicURL = "ERMITE_PUBLIC_URL"
@@ -107,6 +110,8 @@ const MinAdminTokenLen = 32
107110
108111 // Config is the validated server configuration.
109112 type Config struct {
113+ // VideoHosts are exact external hosts allowed to load README videos.
114+ VideoHosts []string
110115 // Addr is the listen address, "host:port".
111116 Addr string
112117 // DataDir is the absolute, clean data root.
@@ -186,6 +191,11 @@ func Load(get func(string) string) (Config, error) {
186191 }
187192 get = resolveFiles(get, &problems)
188193 var c Config
194+ var hostErr error
195+ c.VideoHosts, hostErr = mediapolicy.Parse(get(EnvVideoHosts))
196+ if hostErr != nil {
197+ bad(EnvVideoHosts, "%v", hostErr)
198+ }
189199
190200 c.Addr = get(EnvAddr)
191201 if c.Addr == "" {
@@ -18,10 +18,13 @@ import (
18 "strings"18 "strings"
19 "time"19 "time"
20 "unicode/utf8"20 "unicode/utf8"
21+
22+ "bnema.dev/ermite/internal/mediapolicy"
21 )23 )
22 24
23 // Variable names.25 // Variable names.
24 const (26 const (
27+ EnvVideoHosts = "ERMITE_VIDEO_HOSTS"
25 EnvAddr = "ERMITE_ADDR"28 EnvAddr = "ERMITE_ADDR"
26 EnvDataDir = "ERMITE_DATA_DIR"29 EnvDataDir = "ERMITE_DATA_DIR"
27 EnvPublicURL = "ERMITE_PUBLIC_URL"30 EnvPublicURL = "ERMITE_PUBLIC_URL"
@@ -107,6 +110,8 @@ const MinAdminTokenLen = 32
107 110
108 // Config is the validated server configuration.111 // Config is the validated server configuration.
109 type Config struct {112 type Config struct {
113+ // VideoHosts are exact external hosts allowed to load README videos.
114+ VideoHosts []string
110 // Addr is the listen address, "host:port".115 // Addr is the listen address, "host:port".
111 Addr string116 Addr string
112 // DataDir is the absolute, clean data root.117 // DataDir is the absolute, clean data root.
@@ -186,6 +191,11 @@ func Load(get func(string) string) (Config, error) {
186 }191 }
187 get = resolveFiles(get, &problems)192 get = resolveFiles(get, &problems)
188 var c Config193 var c Config
194+ var hostErr error
195+ c.VideoHosts, hostErr = mediapolicy.Parse(get(EnvVideoHosts))
196+ if hostErr != nil {
197+ bad(EnvVideoHosts, "%v", hostErr)
198+ }
189 199
190 c.Addr = get(EnvAddr)200 c.Addr = get(EnvAddr)
191 if c.Addr == "" {201 if c.Addr == "" {
M internal/config/config_test.go +27 -0
@@ -360,3 +360,30 @@ func TestCIArtifacts(t *testing.T) {
360360 require.Error(t, err, in)
361361 }
362362 }
363+
364+func TestVideoHosts(t *testing.T) {
365+ for _, tc := range []struct {
366+ value string
367+ want []string
368+ bad bool
369+ }{
370+ {"", []string{"github.com", "user-images.githubusercontent.com", "private-user-images.githubusercontent.com", "github-production-user-asset-6210df.s3.amazonaws.com"}, false},
371+ {" Clips.Example , other.example", []string{"clips.example", "other.example"}, false},
372+ {"https://clips.example", nil, true},
373+ {"*.example", nil, true},
374+ {"example.com; script-src *", nil, true},
375+ {"example.com:443", nil, true},
376+ {"example.com,", nil, true},
377+ } {
378+ t.Run(tc.value, func(t *testing.T) {
379+ cfg, err := config.Load(lookup(valid(map[string]string{config.EnvVideoHosts: tc.value})))
380+ if tc.bad {
381+ require.Error(t, err)
382+ assert.Contains(t, err.Error(), config.EnvVideoHosts)
383+ return
384+ }
385+ require.NoError(t, err)
386+ assert.Equal(t, tc.want, cfg.VideoHosts)
387+ })
388+ }
389+}
@@ -360,3 +360,30 @@ func TestCIArtifacts(t *testing.T) {
360 require.Error(t, err, in)360 require.Error(t, err, in)
361 }361 }
362 }362 }
363+
364+func TestVideoHosts(t *testing.T) {
365+ for _, tc := range []struct {
366+ value string
367+ want []string
368+ bad bool
369+ }{
370+ {"", []string{"github.com", "user-images.githubusercontent.com", "private-user-images.githubusercontent.com", "github-production-user-asset-6210df.s3.amazonaws.com"}, false},
371+ {" Clips.Example , other.example", []string{"clips.example", "other.example"}, false},
372+ {"https://clips.example", nil, true},
373+ {"*.example", nil, true},
374+ {"example.com; script-src *", nil, true},
375+ {"example.com:443", nil, true},
376+ {"example.com,", nil, true},
377+ } {
378+ t.Run(tc.value, func(t *testing.T) {
379+ cfg, err := config.Load(lookup(valid(map[string]string{config.EnvVideoHosts: tc.value})))
380+ if tc.bad {
381+ require.Error(t, err)
382+ assert.Contains(t, err.Error(), config.EnvVideoHosts)
383+ return
384+ }
385+ require.NoError(t, err)
386+ assert.Equal(t, tc.want, cfg.VideoHosts)
387+ })
388+ }
389+}
A internal/mediapolicy/hosts.go +73 -0
@@ -0,0 +1,73 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package mediapolicy shares the external video host policy between HTTP and Markdown.
4+package mediapolicy
5+
6+import (
7+ "fmt"
8+ "net/url"
9+ "strings"
10+)
11+
12+// Defaults includes GitHub's user-attachment redirect destination.
13+const Defaults = "github.com,user-images.githubusercontent.com,private-user-images.githubusercontent.com,github-production-user-asset-6210df.s3.amazonaws.com"
14+
15+// Parse accepts comma-separated exact DNS hostnames, never CSP expressions or URLs.
16+func Parse(value string) ([]string, error) {
17+ if value == "" {
18+ value = Defaults
19+ }
20+ var hosts []string
21+ for _, raw := range strings.Split(value, ",") {
22+ host := strings.ToLower(strings.TrimSpace(raw))
23+ if len(host) == 0 || len(host) > 253 {
24+ return nil, fmt.Errorf("must contain exact DNS hostnames")
25+ }
26+ for _, label := range strings.Split(host, ".") {
27+ if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
28+ return nil, fmt.Errorf("must contain exact DNS hostnames")
29+ }
30+ for _, c := range label {
31+ if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' {
32+ return nil, fmt.Errorf("must contain exact DNS hostnames")
33+ }
34+ }
35+ }
36+ hosts = append(hosts, host)
37+ }
38+ return hosts, nil
39+}
40+
41+// Hosts supplies defaults for adapters constructed without explicit configuration.
42+func Hosts(hosts []string) []string {
43+ if hosts != nil {
44+ return hosts
45+ }
46+ defaults, _ := Parse("")
47+ return defaults
48+}
49+
50+// Allows accepts only HTTP(S), without credentials or nonstandard ports.
51+func Allows(u *url.URL, hosts []string) bool {
52+ if u == nil || u.User != nil || (u.Scheme != "https" && u.Scheme != "http") {
53+ return false
54+ }
55+ if port := u.Port(); port != "" && (u.Scheme != "https" || port != "443") && (u.Scheme != "http" || port != "80") {
56+ return false
57+ }
58+ for _, host := range Hosts(hosts) {
59+ if strings.EqualFold(u.Hostname(), host) {
60+ return true
61+ }
62+ }
63+ return false
64+}
65+
66+// Sources returns explicit scheme sources, matching Allows and forbidding wildcards.
67+func Sources(hosts []string) string {
68+ var sources []string
69+ for _, host := range Hosts(hosts) {
70+ sources = append(sources, "https://"+host, "http://"+host)
71+ }
72+ return strings.Join(sources, " ")
73+}
@@ -0,0 +1,73 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package mediapolicy shares the external video host policy between HTTP and Markdown.
4+package mediapolicy
5+
6+import (
7+ "fmt"
8+ "net/url"
9+ "strings"
10+)
11+
12+// Defaults includes GitHub's user-attachment redirect destination.
13+const Defaults = "github.com,user-images.githubusercontent.com,private-user-images.githubusercontent.com,github-production-user-asset-6210df.s3.amazonaws.com"
14+
15+// Parse accepts comma-separated exact DNS hostnames, never CSP expressions or URLs.
16+func Parse(value string) ([]string, error) {
17+ if value == "" {
18+ value = Defaults
19+ }
20+ var hosts []string
21+ for _, raw := range strings.Split(value, ",") {
22+ host := strings.ToLower(strings.TrimSpace(raw))
23+ if len(host) == 0 || len(host) > 253 {
24+ return nil, fmt.Errorf("must contain exact DNS hostnames")
25+ }
26+ for _, label := range strings.Split(host, ".") {
27+ if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
28+ return nil, fmt.Errorf("must contain exact DNS hostnames")
29+ }
30+ for _, c := range label {
31+ if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' {
32+ return nil, fmt.Errorf("must contain exact DNS hostnames")
33+ }
34+ }
35+ }
36+ hosts = append(hosts, host)
37+ }
38+ return hosts, nil
39+}
40+
41+// Hosts supplies defaults for adapters constructed without explicit configuration.
42+func Hosts(hosts []string) []string {
43+ if hosts != nil {
44+ return hosts
45+ }
46+ defaults, _ := Parse("")
47+ return defaults
48+}
49+
50+// Allows accepts only HTTP(S), without credentials or nonstandard ports.
51+func Allows(u *url.URL, hosts []string) bool {
52+ if u == nil || u.User != nil || (u.Scheme != "https" && u.Scheme != "http") {
53+ return false
54+ }
55+ if port := u.Port(); port != "" && (u.Scheme != "https" || port != "443") && (u.Scheme != "http" || port != "80") {
56+ return false
57+ }
58+ for _, host := range Hosts(hosts) {
59+ if strings.EqualFold(u.Hostname(), host) {
60+ return true
61+ }
62+ }
63+ return false
64+}
65+
66+// Sources returns explicit scheme sources, matching Allows and forbidding wildcards.
67+func Sources(hosts []string) string {
68+ var sources []string
69+ for _, host := range Hosts(hosts) {
70+ sources = append(sources, "https://"+host, "http://"+host)
71+ }
72+ return strings.Join(sources, " ")
73+}
A internal/mediapolicy/hosts_test.go +39 -0
@@ -0,0 +1,39 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package mediapolicy
4+
5+import (
6+ "net/url"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestAllows(t *testing.T) {
14+ for _, tc := range []struct {
15+ address string
16+ allowed bool
17+ }{
18+ {"https://github.com/a.mp4", true},
19+ {"http://github.com/a.mp4", true},
20+ {"https://GITHUB.COM:443/a.mp4", true},
21+ {"http://github.com:80/a.mp4", true},
22+ {"https://github.com:8080/a.mp4", false},
23+ {"https://github.com.evil.test/a.mp4", false},
24+ {"https://evil.test@github.com/a.mp4", false},
25+ {"//github.com/a.mp4", false},
26+ {"ftp://github.com/a.mp4", false},
27+ {"https://user-images.githubusercontent.com/a.mp4", true},
28+ {"https://private-user-images.githubusercontent.com/a.mp4", true},
29+ {"https://github-production-user-asset-6210df.s3.amazonaws.com/a.mp4", true},
30+ } {
31+ t.Run(tc.address, func(t *testing.T) {
32+ u, err := url.Parse(tc.address)
33+ require.NoError(t, err)
34+ assert.Equal(t, tc.allowed, Allows(u, nil))
35+ })
36+ }
37+ assert.Equal(t, "https://clips.example http://clips.example", Sources([]string{"clips.example"}))
38+ assert.Empty(t, Sources([]string{}))
39+}
@@ -0,0 +1,39 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package mediapolicy
4+
5+import (
6+ "net/url"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestAllows(t *testing.T) {
14+ for _, tc := range []struct {
15+ address string
16+ allowed bool
17+ }{
18+ {"https://github.com/a.mp4", true},
19+ {"http://github.com/a.mp4", true},
20+ {"https://GITHUB.COM:443/a.mp4", true},
21+ {"http://github.com:80/a.mp4", true},
22+ {"https://github.com:8080/a.mp4", false},
23+ {"https://github.com.evil.test/a.mp4", false},
24+ {"https://evil.test@github.com/a.mp4", false},
25+ {"//github.com/a.mp4", false},
26+ {"ftp://github.com/a.mp4", false},
27+ {"https://user-images.githubusercontent.com/a.mp4", true},
28+ {"https://private-user-images.githubusercontent.com/a.mp4", true},
29+ {"https://github-production-user-asset-6210df.s3.amazonaws.com/a.mp4", true},
30+ } {
31+ t.Run(tc.address, func(t *testing.T) {
32+ u, err := url.Parse(tc.address)
33+ require.NoError(t, err)
34+ assert.Equal(t, tc.allowed, Allows(u, nil))
35+ })
36+ }
37+ assert.Equal(t, "https://clips.example http://clips.example", Sources([]string{"clips.example"}))
38+ assert.Empty(t, Sources([]string{}))
39+}

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 2

Tags 45

↑↓ moveenter openesc close