feat(web): render README videos with native players
- commit
- 78646980caeab38faa4532abcb4c75fa5f53ea31
- author
- brice · · 2026-10-22 14:05 UTC
- committer
- brice · · 2026-10-22 14:34 UTC
- parent
- 80d5067
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
21 files · +471 -47
M docs/configuration.md +14 -0
| @@ -25,6 +25,7 @@ ermite serve | |||
| 25 | 25 | | `ERMITE_TRUSTED_PROXIES` | empty | comma-separated CIDRs of your TLS proxy, so login rate limits see the real client (`X-Forwarded-For`) | | |
| 26 | 26 | | `ERMITE_OWNER` | `owner` | owner login: web header, passkey name, and `<owner>` in CI (`github.repository`). Same rules as repository names | | |
| 27 | 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 | 29 | | `ERMITE_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error` | | |
| 29 | 30 | | `ERMITE_LOG_FORMAT` | `json` | `json` or `console` | | |
| 30 | 31 | ||
| @@ -95,3 +96,16 @@ The hook socket and scripts git calls on each push live in `$ERMITE_DATA_DIR/run | |||
| 95 | 96 | ## Storage schema | |
| 96 | 97 | ||
| 97 | 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 (``, 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 also | |
| 106 | + | renders as a player, including extensionless GitHub user-attachment URLs. | |
| 107 | + | Other images show their alt text; other links keep the existing link policy. | |
| 108 | + | Raw HTML stays disabled. Untrusted public issue Markdown never loads videos. | |
| 109 | + | Raw video types are detected by bytes, not extensions. Seeking supports single | |
| 110 | + | byte ranges; it streams and discards the preceding bytes, so small clips work | |
| 111 | + | 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,16 @@ The hook socket and scripts git calls on each push live in `$ERMITE_DATA_DIR/run | |||||
| 95 | ## Storage schema | 96 | ## 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 (``, 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 also | |||
| 106 | + | renders as a player, including extensionless GitHub user-attachment URLs. | |||
| 107 | + | Other images show their alt text; other links keep the existing link policy. | |||
| 108 | + | Raw HTML stays disabled. Untrusted public issue Markdown never loads videos. | |||
| 109 | + | Raw video types are detected by bytes, not extensions. Seeking supports single | |||
| 110 | + | byte ranges; it streams and discards the preceding bytes, so small clips work | |||
| 111 | + | 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 | |||
| 62 | 62 | ||
| 63 | 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 | 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 | ||
| @@ -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 +4 -2
| @@ -10,6 +10,8 @@ import ( | |||
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/zerowrap" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite/internal/mediapolicy" | |
| 13 | 15 | ) | |
| 14 | 16 | ||
| 15 | 17 | // contentSecurityPolicy allows scripts from the origin plus 'unsafe-eval' | |
| @@ -30,10 +32,10 @@ func isHTTPS(publicURL string) bool { | |||
| 30 | 32 | // withSecurityHeaders sets the security headers on every response, before | |
| 31 | 33 | // the handler runs, so that errors and 404s carry them too. With hsts, the | |
| 32 | 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 { | |
| 34 | 36 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 35 | 37 | h := w.Header() | |
| 36 | - | h.Set("Content-Security-Policy", contentSecurityPolicy) | |
| 38 | + | h.Set("Content-Security-Policy", contentSecurityPolicy+"; media-src 'self' "+mediapolicy.Sources(hosts)) | |
| 37 | 39 | h.Set("X-Frame-Options", "DENY") | |
| 38 | 40 | h.Set("X-Content-Type-Options", "nosniff") | |
| 39 | 41 | 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,10 @@ func isHTTPS(publicURL string) bool { | |||||
| 30 | // withSecurityHeaders sets the security headers on every response, before | 32 | // withSecurityHeaders sets the security headers on every response, before | ||
| 31 | // the handler runs, so that errors and 404s carry them too. With hsts, the | 33 | // the handler runs, so that errors and 404s carry them too. With hsts, the | ||
| 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 { |
| 34 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 36 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 35 | h := w.Header() | 37 | h := w.Header() | ||
| 36 | - | h.Set("Content-Security-Policy", contentSecurityPolicy) | 38 | + | h.Set("Content-Security-Policy", contentSecurityPolicy+"; media-src 'self' "+mediapolicy.Sources(hosts)) |
| 37 | h.Set("X-Frame-Options", "DENY") | 39 | h.Set("X-Frame-Options", "DENY") | ||
| 38 | h.Set("X-Content-Type-Options", "nosniff") | 40 | h.Set("X-Content-Type-Options", "nosniff") | ||
| 39 | h.Set("Referrer-Policy", "same-origin") | 41 | h.Set("Referrer-Policy", "same-origin") | ||
M internal/adapters/httpserver/server.go +3 -1
| @@ -34,6 +34,8 @@ type Route struct { | |||
| 34 | 34 | ||
| 35 | 35 | // Config configures a Server. | |
| 36 | 36 | type Config struct { | |
| 37 | + | // VideoHosts are exact external video hosts; nil uses the defaults. | |
| 38 | + | VideoHosts []string | |
| 37 | 39 | // Addr is the listen address, "host:port". | |
| 38 | 40 | Addr string | |
| 39 | 41 | // PublicURL is the public base URL of the server. When it is https://, | |
| @@ -76,7 +78,7 @@ func New(cfg Config) *Server { | |||
| 76 | 78 | for _, r := range cfg.Routes { | |
| 77 | 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 | 84 | // 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 string | 40 | 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 ( | |||
| 27 | 27 | ) | |
| 28 | 28 | ||
| 29 | 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 | 31 | "X-Frame-Options": "DENY", | |
| 32 | 32 | "X-Content-Type-Options": "nosniff", | |
| 33 | 33 | "Referrer-Policy": "same-origin", | |
| @@ -309,3 +309,20 @@ func TestRunListenError(t *testing.T) { | |||
| 309 | 309 | require.Error(t, err) | |
| 310 | 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 | + | } | |
| @@ -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 +94 -9
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | 15 | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/mediapolicy" | |
| 16 | 17 | "github.com/yuin/goldmark" | |
| 17 | 18 | "github.com/yuin/goldmark/ast" | |
| 18 | 19 | "github.com/yuin/goldmark/extension" | |
| @@ -36,14 +37,16 @@ const headingIDPrefix = "md-" | |||
| 36 | 37 | // - #fragments point at the (prefixed) heading ids; | |
| 37 | 38 | // - everything else (javascript:, data:, vbscript:, file:, mailto:, | |
| 38 | 39 | // 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 video hosts become players. | |
| 41 | 43 | type markdown struct { | |
| 42 | - | md goldmark.Markdown | |
| 44 | + | md goldmark.Markdown | |
| 45 | + | videoHosts []string | |
| 43 | 46 | } | |
| 44 | 47 | ||
| 45 | - | func newMarkdown() *markdown { | |
| 46 | - | return &markdown{md: goldmark.New( | |
| 48 | + | func newMarkdown(hosts ...string) *markdown { | |
| 49 | + | return &markdown{videoHosts: mediapolicy.Hosts(hosts), md: goldmark.New( | |
| 47 | 50 | goldmark.WithExtensions(extension.GFM), | |
| 48 | 51 | goldmark.WithParserOptions(parser.WithAutoHeadingID()), | |
| 49 | 52 | goldmark.WithRendererOptions( | |
| @@ -94,7 +97,7 @@ type refResolver struct { | |||
| 94 | 97 | ||
| 95 | 98 | // renderUntrusted renders Markdown written by an anonymous visitor (an issue | |
| 96 | 99 | // approved from the public form). On top of the policy of render, which already | |
| 97 | - | // drops raw HTML and never loads an image: | |
| 100 | + | // drops raw HTML, this renderer never loads an image or video: | |
| 98 | 101 | // - only http, https and mailto links are kept, every one with | |
| 99 | 102 | // rel="nofollow noopener noreferrer ugc"; relative links, #fragments and | |
| 100 | 103 | // every other scheme lose their link and keep the text; | |
| @@ -108,6 +111,7 @@ func (m *markdown) renderPolicy(source []byte, pol linkPolicy, res refResolver) | |||
| 108 | 111 | pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()})) | |
| 109 | 112 | doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc)) | |
| 110 | 113 | pol.source = source | |
| 114 | + | pol.videoHosts = m.videoHosts | |
| 111 | 115 | // Collect eligible text before the policy unwraps rejected links: their | |
| 112 | 116 | // labels must not accidentally become references either. | |
| 113 | 117 | var refs []*ast.Text | |
| @@ -196,7 +200,9 @@ type linkPolicy struct { | |||
| 196 | 200 | // idPrefix is the prefix of the heading ids that #fragment links point at. | |
| 197 | 201 | idPrefix string | |
| 198 | 202 | // untrusted is the policy of text from an anonymous visitor. | |
| 199 | - | untrusted bool | |
| 203 | + | untrusted bool | |
| 204 | + | videoHosts []string | |
| 205 | + | rawVideo bool | |
| 200 | 206 | } | |
| 201 | 207 | ||
| 202 | 208 | const ( | |
| @@ -233,6 +239,9 @@ func (p linkPolicy) apply(doc ast.Node) { | |||
| 233 | 239 | } | |
| 234 | 240 | for _, n := range links { | |
| 235 | 241 | l := n.(*ast.Link) | |
| 242 | + | if p.standaloneVideo(l, string(l.Destination), plainText(l, p.source)) { | |
| 243 | + | continue | |
| 244 | + | } | |
| 236 | 245 | dest, kind := p.target(string(l.Destination)) | |
| 237 | 246 | if kind == dropped { | |
| 238 | 247 | unwrap(l) | |
| @@ -246,6 +255,9 @@ func (p linkPolicy) apply(doc ast.Node) { | |||
| 246 | 255 | } | |
| 247 | 256 | for _, n := range autos { | |
| 248 | 257 | a := n.(*ast.AutoLink) | |
| 258 | + | if p.standaloneVideo(a, string(a.URL(p.source)), string(a.Label(p.source))) { | |
| 259 | + | continue | |
| 260 | + | } | |
| 249 | 261 | if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) { | |
| 250 | 262 | a.SetAttributeString("rel", p.rel()) | |
| 251 | 263 | continue | |
| @@ -268,12 +280,16 @@ func unwrap(n ast.Node) { | |||
| 268 | 280 | parent.RemoveChild(parent, n) | |
| 269 | 281 | } | |
| 270 | 282 | ||
| 271 | - | // image replaces an image by its alt text. | |
| 283 | + | // image replaces a video image with a player, otherwise its alt text. | |
| 272 | 284 | func (p linkPolicy) image(img *ast.Image) { | |
| 273 | 285 | alt := plainText(img, p.source) | |
| 274 | 286 | if alt == "" { | |
| 275 | 287 | alt = "image" | |
| 276 | 288 | } | |
| 289 | + | if src, ok := p.videoTarget(string(img.Destination), true); ok { | |
| 290 | + | img.Parent().ReplaceChild(img.Parent(), img, &videoNode{src: src, alt: alt}) | |
| 291 | + | return | |
| 292 | + | } | |
| 277 | 293 | img.Parent().ReplaceChild(img.Parent(), img, &altSpan{alt: alt}) | |
| 278 | 294 | } | |
| 279 | 295 | ||
| @@ -333,7 +349,11 @@ func (p linkPolicy) target(dest string) (string, linkKind) { | |||
| 333 | 349 | rel = strings.TrimPrefix(rel, "/") | |
| 334 | 350 | href := repoHref(p.repo) | |
| 335 | 351 | if rel != "" { | |
| 336 | - | href = repoHref(p.repo, "blob", p.ref, rel) | |
| 352 | + | route := "blob" | |
| 353 | + | if p.rawVideo { | |
| 354 | + | route = "raw" | |
| 355 | + | } | |
| 356 | + | href = repoHref(p.repo, route, p.ref, rel) | |
| 337 | 357 | } | |
| 338 | 358 | if u.Fragment != "" { | |
| 339 | 359 | href += "#" + url.PathEscape(u.Fragment) | |
| @@ -400,6 +420,17 @@ func (n *altSpan) Dump(source []byte, level int) { ast.DumpHelper(n, source, lev | |||
| 400 | 420 | type altRenderer struct{} | |
| 401 | 421 | ||
| 402 | 422 | func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) { | |
| 423 | + | r.Register(kindVideo, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { | |
| 424 | + | if entering { | |
| 425 | + | v := n.(*videoNode) | |
| 426 | + | _, _ = w.WriteString(`<video controls preload="metadata" src="`) | |
| 427 | + | _, _ = w.Write(util.EscapeHTML([]byte(v.src))) | |
| 428 | + | _, _ = w.WriteString(`" aria-label="`) | |
| 429 | + | _, _ = w.Write(util.EscapeHTML([]byte(v.alt))) | |
| 430 | + | _, _ = w.WriteString(`"></video>`) | |
| 431 | + | } | |
| 432 | + | return ast.WalkSkipChildren, nil | |
| 433 | + | }) | |
| 403 | 434 | r.Register(kindAlt, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { | |
| 404 | 435 | if entering { | |
| 405 | 436 | _, _ = w.WriteString(`<span class="alt">[`) | |
| @@ -488,3 +519,57 @@ func references(t *ast.Text, source []byte, res refResolver) { | |||
| 488 | 519 | parent.InsertBefore(parent, t, tail) | |
| 489 | 520 | parent.RemoveChild(parent, t) | |
| 490 | 521 | } | |
| 522 | + | ||
| 523 | + | // videoTarget uses the ordinary destination policy before restricting media origins. | |
| 524 | + | func (p linkPolicy) videoTarget(dest string, requireExtension bool) (string, bool) { | |
| 525 | + | if p.untrusted { | |
| 526 | + | return "", false | |
| 527 | + | } | |
| 528 | + | u, err := url.Parse(strings.TrimSpace(dest)) | |
| 529 | + | if err != nil { | |
| 530 | + | return "", false | |
| 531 | + | } | |
| 532 | + | if requireExtension { | |
| 533 | + | switch strings.ToLower(path.Ext(u.Path)) { | |
| 534 | + | case ".mp4", ".webm", ".mov", ".m4v", ".ogv": | |
| 535 | + | default: | |
| 536 | + | return "", false | |
| 537 | + | } | |
| 538 | + | } | |
| 539 | + | p.rawVideo = true | |
| 540 | + | target, kind := p.target(dest) | |
| 541 | + | if kind == dropped || strings.HasPrefix(target, "#") { | |
| 542 | + | return "", false | |
| 543 | + | } | |
| 544 | + | if kind == external && !mediapolicy.Allows(u, p.videoHosts) { | |
| 545 | + | return "", false | |
| 546 | + | } | |
| 547 | + | return target, true | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | func (p linkPolicy) standaloneVideo(n ast.Node, dest, label string) bool { | |
| 551 | + | parent := n.Parent() | |
| 552 | + | if parent == nil || parent.Kind() != ast.KindParagraph || parent.ChildCount() != 1 { | |
| 553 | + | return false | |
| 554 | + | } | |
| 555 | + | u, err := url.Parse(dest) | |
| 556 | + | if err != nil || !mediapolicy.Allows(u, p.videoHosts) { | |
| 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) } | |
| @@ -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,16 @@ 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 design | 40 | + | // - 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 video hosts become players. | |||
| 41 | type markdown struct { | 43 | type markdown struct { | ||
| 42 | - | md goldmark.Markdown | 44 | + | md goldmark.Markdown |
| 45 | + | videoHosts []string | |||
| 43 | } | 46 | } | ||
| 44 | 47 | ||||
| 45 | - | func newMarkdown() *markdown { | 48 | + | func newMarkdown(hosts ...string) *markdown { |
| 46 | - | return &markdown{md: goldmark.New( | 49 | + | return &markdown{videoHosts: mediapolicy.Hosts(hosts), md: goldmark.New( |
| 47 | goldmark.WithExtensions(extension.GFM), | 50 | goldmark.WithExtensions(extension.GFM), | ||
| 48 | goldmark.WithParserOptions(parser.WithAutoHeadingID()), | 51 | goldmark.WithParserOptions(parser.WithAutoHeadingID()), | ||
| 49 | goldmark.WithRendererOptions( | 52 | goldmark.WithRendererOptions( | ||
| @@ -94,7 +97,7 @@ type refResolver struct { | |||||
| 94 | 97 | ||||
| 95 | // renderUntrusted renders Markdown written by an anonymous visitor (an issue | 98 | // renderUntrusted renders Markdown written by an anonymous visitor (an issue | ||
| 96 | // approved from the public form). On top of the policy of render, which already | 99 | // approved from the public form). On top of the policy of render, which already | ||
| 97 | - | // drops raw HTML and never loads an image: | 100 | + | // drops raw HTML, this renderer never loads an image or video: |
| 98 | // - only http, https and mailto links are kept, every one with | 101 | // - only http, https and mailto links are kept, every one with | ||
| 99 | // rel="nofollow noopener noreferrer ugc"; relative links, #fragments and | 102 | // rel="nofollow noopener noreferrer ugc"; relative links, #fragments and | ||
| 100 | // every other scheme lose their link and keep the text; | 103 | // every other scheme lose their link and keep the text; | ||
| @@ -108,6 +111,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()})) | 111 | 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)) | 112 | doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc)) | ||
| 110 | pol.source = source | 113 | pol.source = source | ||
| 114 | + | pol.videoHosts = m.videoHosts | |||
| 111 | // Collect eligible text before the policy unwraps rejected links: their | 115 | // Collect eligible text before the policy unwraps rejected links: their | ||
| 112 | // labels must not accidentally become references either. | 116 | // labels must not accidentally become references either. | ||
| 113 | var refs []*ast.Text | 117 | var refs []*ast.Text | ||
| @@ -196,7 +200,9 @@ type linkPolicy struct { | |||||
| 196 | // idPrefix is the prefix of the heading ids that #fragment links point at. | 200 | // idPrefix is the prefix of the heading ids that #fragment links point at. | ||
| 197 | idPrefix string | 201 | idPrefix string | ||
| 198 | // untrusted is the policy of text from an anonymous visitor. | 202 | // untrusted is the policy of text from an anonymous visitor. | ||
| 199 | - | untrusted bool | 203 | + | untrusted bool |
| 204 | + | videoHosts []string | |||
| 205 | + | rawVideo bool | |||
| 200 | } | 206 | } | ||
| 201 | 207 | ||||
| 202 | const ( | 208 | const ( | ||
| @@ -233,6 +239,9 @@ func (p linkPolicy) apply(doc ast.Node) { | |||||
| 233 | } | 239 | } | ||
| 234 | for _, n := range links { | 240 | for _, n := range links { | ||
| 235 | l := n.(*ast.Link) | 241 | l := n.(*ast.Link) | ||
| 242 | + | if p.standaloneVideo(l, string(l.Destination), plainText(l, p.source)) { | |||
| 243 | + | continue | |||
| 244 | + | } | |||
| 236 | dest, kind := p.target(string(l.Destination)) | 245 | dest, kind := p.target(string(l.Destination)) | ||
| 237 | if kind == dropped { | 246 | if kind == dropped { | ||
| 238 | unwrap(l) | 247 | unwrap(l) | ||
| @@ -246,6 +255,9 @@ func (p linkPolicy) apply(doc ast.Node) { | |||||
| 246 | } | 255 | } | ||
| 247 | for _, n := range autos { | 256 | for _, n := range autos { | ||
| 248 | a := n.(*ast.AutoLink) | 257 | a := n.(*ast.AutoLink) | ||
| 258 | + | if p.standaloneVideo(a, string(a.URL(p.source)), string(a.Label(p.source))) { | |||
| 259 | + | continue | |||
| 260 | + | } | |||
| 249 | if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) { | 261 | if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) { | ||
| 250 | a.SetAttributeString("rel", p.rel()) | 262 | a.SetAttributeString("rel", p.rel()) | ||
| 251 | continue | 263 | continue | ||
| @@ -268,12 +280,16 @@ func unwrap(n ast.Node) { | |||||
| 268 | parent.RemoveChild(parent, n) | 280 | parent.RemoveChild(parent, n) | ||
| 269 | } | 281 | } | ||
| 270 | 282 | ||||
| 271 | - | // image replaces an image by its alt text. | 283 | + | // image replaces a video image with a player, otherwise its alt text. |
| 272 | func (p linkPolicy) image(img *ast.Image) { | 284 | func (p linkPolicy) image(img *ast.Image) { | ||
| 273 | alt := plainText(img, p.source) | 285 | alt := plainText(img, p.source) | ||
| 274 | if alt == "" { | 286 | if alt == "" { | ||
| 275 | alt = "image" | 287 | alt = "image" | ||
| 276 | } | 288 | } | ||
| 289 | + | if src, ok := p.videoTarget(string(img.Destination), true); ok { | |||
| 290 | + | img.Parent().ReplaceChild(img.Parent(), img, &videoNode{src: src, alt: alt}) | |||
| 291 | + | return | |||
| 292 | + | } | |||
| 277 | img.Parent().ReplaceChild(img.Parent(), img, &altSpan{alt: alt}) | 293 | img.Parent().ReplaceChild(img.Parent(), img, &altSpan{alt: alt}) | ||
| 278 | } | 294 | } | ||
| 279 | 295 | ||||
| @@ -333,7 +349,11 @@ func (p linkPolicy) target(dest string) (string, linkKind) { | |||||
| 333 | rel = strings.TrimPrefix(rel, "/") | 349 | rel = strings.TrimPrefix(rel, "/") | ||
| 334 | href := repoHref(p.repo) | 350 | href := repoHref(p.repo) | ||
| 335 | if rel != "" { | 351 | if rel != "" { | ||
| 336 | - | href = repoHref(p.repo, "blob", p.ref, rel) | 352 | + | route := "blob" |
| 353 | + | if p.rawVideo { | |||
| 354 | + | route = "raw" | |||
| 355 | + | } | |||
| 356 | + | href = repoHref(p.repo, route, p.ref, rel) | |||
| 337 | } | 357 | } | ||
| 338 | if u.Fragment != "" { | 358 | if u.Fragment != "" { | ||
| 339 | href += "#" + url.PathEscape(u.Fragment) | 359 | href += "#" + url.PathEscape(u.Fragment) | ||
| @@ -400,6 +420,17 @@ func (n *altSpan) Dump(source []byte, level int) { ast.DumpHelper(n, source, lev | |||||
| 400 | type altRenderer struct{} | 420 | type altRenderer struct{} | ||
| 401 | 421 | ||||
| 402 | func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) { | 422 | func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) { | ||
| 423 | + | r.Register(kindVideo, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { | |||
| 424 | + | if entering { | |||
| 425 | + | v := n.(*videoNode) | |||
| 426 | + | _, _ = w.WriteString(`<video controls preload="metadata" src="`) | |||
| 427 | + | _, _ = w.Write(util.EscapeHTML([]byte(v.src))) | |||
| 428 | + | _, _ = w.WriteString(`" aria-label="`) | |||
| 429 | + | _, _ = w.Write(util.EscapeHTML([]byte(v.alt))) | |||
| 430 | + | _, _ = w.WriteString(`"></video>`) | |||
| 431 | + | } | |||
| 432 | + | return ast.WalkSkipChildren, nil | |||
| 433 | + | }) | |||
| 403 | r.Register(kindAlt, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { | 434 | r.Register(kindAlt, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { | ||
| 404 | if entering { | 435 | if entering { | ||
| 405 | _, _ = w.WriteString(`<span class="alt">[`) | 436 | _, _ = w.WriteString(`<span class="alt">[`) | ||
| @@ -488,3 +519,57 @@ func references(t *ast.Text, source []byte, res refResolver) { | |||||
| 488 | parent.InsertBefore(parent, t, tail) | 519 | parent.InsertBefore(parent, t, tail) | ||
| 489 | parent.RemoveChild(parent, t) | 520 | parent.RemoveChild(parent, t) | ||
| 490 | } | 521 | } | ||
| 522 | + | ||||
| 523 | + | // videoTarget uses the ordinary destination policy before restricting media origins. | |||
| 524 | + | func (p linkPolicy) videoTarget(dest string, requireExtension bool) (string, bool) { | |||
| 525 | + | if p.untrusted { | |||
| 526 | + | return "", false | |||
| 527 | + | } | |||
| 528 | + | u, err := url.Parse(strings.TrimSpace(dest)) | |||
| 529 | + | if err != nil { | |||
| 530 | + | return "", false | |||
| 531 | + | } | |||
| 532 | + | if requireExtension { | |||
| 533 | + | switch strings.ToLower(path.Ext(u.Path)) { | |||
| 534 | + | case ".mp4", ".webm", ".mov", ".m4v", ".ogv": | |||
| 535 | + | default: | |||
| 536 | + | return "", false | |||
| 537 | + | } | |||
| 538 | + | } | |||
| 539 | + | p.rawVideo = true | |||
| 540 | + | target, kind := p.target(dest) | |||
| 541 | + | if kind == dropped || strings.HasPrefix(target, "#") { | |||
| 542 | + | return "", false | |||
| 543 | + | } | |||
| 544 | + | if kind == external && !mediapolicy.Allows(u, p.videoHosts) { | |||
| 545 | + | return "", false | |||
| 546 | + | } | |||
| 547 | + | return target, true | |||
| 548 | + | } | |||
| 549 | + | ||||
| 550 | + | func (p linkPolicy) standaloneVideo(n ast.Node, dest, label string) bool { | |||
| 551 | + | parent := n.Parent() | |||
| 552 | + | if parent == nil || parent.Kind() != ast.KindParagraph || parent.ChildCount() != 1 { | |||
| 553 | + | return false | |||
| 554 | + | } | |||
| 555 | + | u, err := url.Parse(dest) | |||
| 556 | + | if err != nil || !mediapolicy.Allows(u, p.videoHosts) { | |||
| 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) } | |||
M internal/adapters/web/markdown_test.go +45 -0
| @@ -383,3 +383,48 @@ func TestMarkdownHighlightDocumentBudget(t *testing.T) { | |||
| 383 | 383 | assert.LessOrEqual(t, strings.Count(h, `<pre class="chroma">`), 4) | |
| 384 | 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", ``, `src="/demo/raw/main/docs/clips/demo.mp4" aria-label="demo"`, true}, | |
| 393 | + | {"root mov", ``, `src="/demo/raw/main/clip.MOV"`, true}, | |
| 394 | + | {"webm", ``, `src="/demo/raw/main/docs/clip.webm"`, true}, | |
| 395 | + | {"m4v", ``, `src="/demo/raw/main/docs/clip.m4v"`, true}, | |
| 396 | + | {"ogv", ``, `src="/demo/raw/main/docs/clip.ogv"`, true}, | |
| 397 | + | {"external image", ``, `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 | + | {"inline link", `see [clip](https://github.com/user-attachments/assets/uuid) here`, `<a href=`, false}, | |
| 402 | + | {"blocked image", ``, `<span class="alt">[demo]</span>`, false}, | |
| 403 | + | {"blocked link", `https://evil.example/demo.mp4`, `<a href=`, false}, | |
| 404 | + | {"host suffix", ``, `<span class="alt">`, false}, | |
| 405 | + | {"credentials", ``, `<span class="alt">`, false}, | |
| 406 | + | {"protocol relative", ``, `<span class="alt">`, false}, | |
| 407 | + | {"javascript", ``, `<span class="alt">`, false}, | |
| 408 | + | {"ordinary image", ``, `<span class="alt">`, false}, | |
| 409 | + | {"escaped alt", ``, `aria-label="a " & \<b\>"`, true}, | |
| 410 | + | {"escaped path", ``, `src="/demo/raw/main/docs/a%22&b.mp4"`, true}, | |
| 411 | + | } { | |
| 412 | + | t.Run(tc.name, func(t *testing.T) { | |
| 413 | + | h, _ := renderMD(t, tc.source) | |
| 414 | + | assert.Contains(t, h, tc.want) | |
| 415 | + | assert.Equal(t, tc.video, strings.Contains(h, `<video controls preload="metadata"`)) | |
| 416 | + | untrusted, err := newMarkdown().renderUntrusted([]byte(tc.source), "u-", nil) | |
| 417 | + | require.NoError(t, err) | |
| 418 | + | assert.NotContains(t, string(untrusted), "<video") | |
| 419 | + | }) | |
| 420 | + | } | |
| 421 | + | t.Run("configured host", func(t *testing.T) { | |
| 422 | + | m := newMarkdown("clips.example") | |
| 423 | + | h, _, err := m.render([]byte(``), "demo", "main", "") | |
| 424 | + | require.NoError(t, err) | |
| 425 | + | assert.Contains(t, string(h), "<video") | |
| 426 | + | h, _, err = m.render([]byte(``), "demo", "main", "") | |
| 427 | + | require.NoError(t, err) | |
| 428 | + | assert.NotContains(t, string(h), "<video") | |
| 429 | + | }) | |
| 430 | + | } | |
| @@ -383,3 +383,48 @@ 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", ``, `src="/demo/raw/main/docs/clips/demo.mp4" aria-label="demo"`, true}, | |||
| 393 | + | {"root mov", ``, `src="/demo/raw/main/clip.MOV"`, true}, | |||
| 394 | + | {"webm", ``, `src="/demo/raw/main/docs/clip.webm"`, true}, | |||
| 395 | + | {"m4v", ``, `src="/demo/raw/main/docs/clip.m4v"`, true}, | |||
| 396 | + | {"ogv", ``, `src="/demo/raw/main/docs/clip.ogv"`, true}, | |||
| 397 | + | {"external image", ``, `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 | + | {"inline link", `see [clip](https://github.com/user-attachments/assets/uuid) here`, `<a href=`, false}, | |||
| 402 | + | {"blocked image", ``, `<span class="alt">[demo]</span>`, false}, | |||
| 403 | + | {"blocked link", `https://evil.example/demo.mp4`, `<a href=`, false}, | |||
| 404 | + | {"host suffix", ``, `<span class="alt">`, false}, | |||
| 405 | + | {"credentials", ``, `<span class="alt">`, false}, | |||
| 406 | + | {"protocol relative", ``, `<span class="alt">`, false}, | |||
| 407 | + | {"javascript", ``, `<span class="alt">`, false}, | |||
| 408 | + | {"ordinary image", ``, `<span class="alt">`, false}, | |||
| 409 | + | {"escaped alt", ``, `aria-label="a " & \<b\>"`, true}, | |||
| 410 | + | {"escaped path", ``, `src="/demo/raw/main/docs/a%22&b.mp4"`, true}, | |||
| 411 | + | } { | |||
| 412 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 413 | + | h, _ := renderMD(t, tc.source) | |||
| 414 | + | assert.Contains(t, h, tc.want) | |||
| 415 | + | assert.Equal(t, tc.video, strings.Contains(h, `<video controls preload="metadata"`)) | |||
| 416 | + | untrusted, err := newMarkdown().renderUntrusted([]byte(tc.source), "u-", nil) | |||
| 417 | + | require.NoError(t, err) | |||
| 418 | + | assert.NotContains(t, string(untrusted), "<video") | |||
| 419 | + | }) | |||
| 420 | + | } | |||
| 421 | + | t.Run("configured host", func(t *testing.T) { | |||
| 422 | + | m := newMarkdown("clips.example") | |||
| 423 | + | h, _, err := m.render([]byte(``), "demo", "main", "") | |||
| 424 | + | require.NoError(t, err) | |||
| 425 | + | assert.Contains(t, string(h), "<video") | |||
| 426 | + | h, _, err = m.render([]byte(``), "demo", "main", "") | |||
| 427 | + | require.NoError(t, err) | |||
| 428 | + | assert.NotContains(t, string(h), "<video") | |||
| 429 | + | }) | |||
| 430 | + | } | |||
M internal/adapters/web/raw.go +57 -25
| @@ -3,8 +3,8 @@ | |||
| 3 | 3 | package web | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "bytes" | |
| 6 | 7 | "context" | |
| 7 | - | "errors" | |
| 8 | 8 | "io" | |
| 9 | 9 | "mime" | |
| 10 | 10 | "net/http" | |
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "github.com/bnema/zerowrap" | |
| 16 | 16 | ||
| 17 | 17 | "bnema.dev/ermite/internal/domain" | |
| 18 | + | "bnema.dev/ermite/internal/httprange" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | // rawTimeout bounds the stream of one file to the visitor. | |
| @@ -28,7 +29,7 @@ const rawChunkSize = 64 << 10 | |||
| 28 | 29 | const rawCSP = "default-src 'none'; sandbox" | |
| 29 | 30 | ||
| 30 | 31 | // 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 | |
| 32 | 33 | // attachment of unknown type. A file is never served as HTML or SVG, so what | |
| 33 | 34 | // a repository holds cannot run in the origin of the forge. | |
| 34 | 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 | 80 | w.WriteHeader(http.StatusNotModified) | |
| 80 | 81 | return | |
| 81 | 82 | } | |
| 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) | |
| 84 | 93 | return | |
| 85 | 94 | } | |
| 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)) | |
| 88 | 99 | } | |
| 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 { | |
| 91 | 103 | return | |
| 92 | 104 | } | |
| 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 | |
| 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 | 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 | 136 | if img := sniffImage(b.buf); img != "" { | |
| 131 | 137 | return img, false | |
| 132 | 138 | } | |
| 139 | + | if video := sniffVideo(b.buf); video != "" { | |
| 140 | + | return video, false | |
| 141 | + | } | |
| 133 | 142 | if !b.info.Binary { | |
| 134 | 143 | return "text/plain; charset=utf-8", false | |
| 135 | 144 | } | |
| 136 | 145 | 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 | + | } | |
| @@ -3,8 +3,8 @@ | |||||
| 3 | package web | 3 | 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 four | 31 | // 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 |
| 32 | // attachment of unknown type. A file is never served as HTML or SVG, so what | 33 | // 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 | return | 81 | 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 | return | 93 | return | ||
| 85 | } | 94 | } | ||
| 86 | - | if _, err := w.Write(b.buf); err != nil { | 95 | + | status := http.StatusOK |
| 87 | - | return | 96 | + | if partial { |
| 97 | + | status = http.StatusPartialContent | |||
| 98 | + | h.Set("Content-Range", httprange.ContentRange(offset, length, b.info.Size)) | |||
| 88 | } | 99 | } | ||
| 89 | - | b.buf = nil | 100 | + | h.Set("Content-Length", strconv.FormatInt(length, 10)) |
| 90 | - | if b.eof { | 101 | + | w.WriteHeader(status) |
| 102 | + | if r.Method == http.MethodHead { | |||
| 91 | return | 103 | 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 | - | return | 110 | + | 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, false | 137 | 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", false | 143 | return "text/plain; charset=utf-8", false | ||
| 135 | } | 144 | } | ||
| 136 | return "application/octet-stream", true | 145 | 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) { | |||
| 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 | + | } | |
| @@ -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 @@ | |||
| 55 | 55 | & hr { margin: var(--s5) 0; } | |
| 56 | 56 | & .alt { color: var(--ink-mute); } | |
| 57 | 57 | & img { display: none; } | |
| 58 | + | & video { max-width: 100%; } | |
| 58 | 59 | } | |
| 59 | 60 | } | |
| @@ -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 { max-width: 100%; } | |||
| 58 | } | 59 | } | ||
| 59 | } | 60 | } | ||
M internal/adapters/web/web.go +3 -1
| @@ -47,6 +47,8 @@ const requestTimeout = 30 * time.Second | |||
| 47 | 47 | ||
| 48 | 48 | // Config configures a Server. | |
| 49 | 49 | type Config struct { | |
| 50 | + | // VideoHosts are exact external video hosts; nil uses the defaults. | |
| 51 | + | VideoHosts []string | |
| 50 | 52 | // Mirrors answers the push mirror badge shown to repository admins. Without it there is | |
| 51 | 53 | // no badge. Leave it unset rather than assigning a typed nil pointer. | |
| 52 | 54 | Mirrors ports.MirrorWarner | |
| @@ -174,7 +176,7 @@ func New(cfg Config) (*Server, error) { | |||
| 174 | 176 | return nil, errors.New("web: sessions need a failure limiter") | |
| 175 | 177 | } | |
| 176 | 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 | 180 | pathCache: newPathCache(pathCacheBytes, pathBuilds), | |
| 179 | 181 | licenses: newPathCache(1<<20, homeWorkers), | |
| 180 | 182 | 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 is | 52 | // 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.MirrorWarner | 54 | 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 | |||
| 521 | 521 | f.server = httpserver.New(httpserver.Config{ | |
| 522 | 522 | Addr: cfg.Addr, | |
| 523 | 523 | PublicURL: cfg.PublicURL, | |
| 524 | + | VideoHosts: cfg.VideoHosts, | |
| 524 | 525 | HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)}, | |
| 525 | 526 | Routes: routes, | |
| 526 | 527 | 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 { | |||
| 24 | 24 | Long: "Run the server. It is configured by the environment only:\n" + | |
| 25 | 25 | config.EnvAddr + ", " + config.EnvDataDir + ", " + config.EnvPublicURL + ", " + config.EnvAdminToken + ",\n" + | |
| 26 | 26 | config.EnvTrustedProxies + ", " + config.EnvGit + ", " + config.EnvSourceURL + ", " + | |
| 27 | - | config.EnvLogLevel + ", " + config.EnvLogFormat + ",\n" + | |
| 27 | + | config.EnvVideoHosts + ", " + config.EnvLogLevel + ", " + config.EnvLogFormat + ",\n" + | |
| 28 | 28 | config.EnvCommitterName + ", " + config.EnvCommitterEmail + ".", | |
| 29 | 29 | Args: cobra.NoArgs, | |
| 30 | 30 | 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 | |||
| 62 | 62 | Mirrors: f.Mirrors, | |
| 63 | 63 | Owner: cfg.Owner, | |
| 64 | 64 | PublicURL: cfg.PublicURL, | |
| 65 | + | VideoHosts: cfg.VideoHosts, | |
| 65 | 66 | SourceURL: cfg.SourceURL, | |
| 66 | 67 | Version: serverVersion(), | |
| 67 | 68 | ||
| @@ -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) { | |||
| 465 | 465 | h := newHarness(t, harnessOpts{}) | |
| 466 | 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 | 469 | for _, p := range []string{ | |
| 470 | 470 | "/files/tree/main", "/files/tree/main/docs", "/files/blob/main/main.go", "/files/blob/main/docs/guide.md", | |
| 471 | 471 | "/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) { | |||
| 203 | 203 | } | |
| 204 | 204 | ||
| 205 | 205 | // 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 | |
| 207 | 207 | // every page carries the security headers. | |
| 208 | 208 | func TestWebContentSecurity(t *testing.T) { | |
| 209 | 209 | h := newHarness(t, harnessOpts{}) | |
| 210 | 210 | h.webRepo("demo") | |
| 211 | 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 | 214 | for _, p := range []string{"/", "/demo", "/demo/refs", "/quiet", "/nope", "/demo/commits/main"} { | |
| 215 | 215 | status, hdr, body := h.get(p) | |
| 216 | 216 | 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 from | 205 | // 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 |
| 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 ( | |||
| 36 | 36 | layerAdapter packageLayer = "adapter" | |
| 37 | 37 | layerClientLimit packageLayer = "clientlimit" | |
| 38 | 38 | layerHTTPRange packageLayer = "httprange" | |
| 39 | + | layerMediaPolicy packageLayer = "mediapolicy" | |
| 39 | 40 | layerConfig packageLayer = "config" | |
| 40 | 41 | layerPorts packageLayer = "ports" | |
| 41 | 42 | layerDomain packageLayer = "domain" | |
| @@ -58,15 +59,18 @@ const ( | |||
| 58 | 59 | // - internal/clientlimit is infrastructure shared by the HTTP adapters. | |
| 59 | 60 | // - internal/httprange is infrastructure shared by the HTTP adapters that | |
| 60 | 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 | 64 | // - internal/config parses the environment into plain values. | |
| 62 | 65 | var productionDependencies = map[packageLayer]map[packageLayer]bool{ | |
| 63 | 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 | 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 | 70 | layerClientLimit: {layerPorts: true}, | |
| 68 | 71 | layerHTTPRange: {}, | |
| 69 | - | layerConfig: {}, | |
| 72 | + | layerConfig: {layerMediaPolicy: true}, | |
| 73 | + | layerMediaPolicy: {}, | |
| 70 | 74 | layerPorts: {layerDomain: true}, | |
| 71 | 75 | layerDomain: {layerDomain: true}, | |
| 72 | 76 | layerGenerated: {layerGenerated: true}, | |
| @@ -331,6 +335,8 @@ func classifyPackage(path string) (packageLayer, error) { | |||
| 331 | 335 | return layerClientLimit, nil | |
| 332 | 336 | case under(path, p("internal/httprange")): | |
| 333 | 337 | return layerHTTPRange, nil | |
| 338 | + | case under(path, p("internal/mediapolicy")): | |
| 339 | + | return layerMediaPolicy, nil | |
| 334 | 340 | case under(path, p("internal/config")): | |
| 335 | 341 | return layerConfig, nil | |
| 336 | 342 | 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 that | 60 | // - 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, nil | 335 | return layerClientLimit, nil | ||
| 332 | case under(path, p("internal/httprange")): | 336 | case under(path, p("internal/httprange")): | ||
| 333 | return layerHTTPRange, nil | 337 | 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, nil | 341 | 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 ( | |||
| 18 | 18 | "strings" | |
| 19 | 19 | "time" | |
| 20 | 20 | "unicode/utf8" | |
| 21 | + | ||
| 22 | + | "bnema.dev/ermite/internal/mediapolicy" | |
| 21 | 23 | ) | |
| 22 | 24 | ||
| 23 | 25 | // Variable names. | |
| 24 | 26 | const ( | |
| 27 | + | EnvVideoHosts = "ERMITE_VIDEO_HOSTS" | |
| 25 | 28 | EnvAddr = "ERMITE_ADDR" | |
| 26 | 29 | EnvDataDir = "ERMITE_DATA_DIR" | |
| 27 | 30 | EnvPublicURL = "ERMITE_PUBLIC_URL" | |
| @@ -107,6 +110,8 @@ const MinAdminTokenLen = 32 | |||
| 107 | 110 | ||
| 108 | 111 | // Config is the validated server configuration. | |
| 109 | 112 | type Config struct { | |
| 113 | + | // VideoHosts are exact external hosts allowed to load README videos. | |
| 114 | + | VideoHosts []string | |
| 110 | 115 | // Addr is the listen address, "host:port". | |
| 111 | 116 | Addr string | |
| 112 | 117 | // DataDir is the absolute, clean data root. | |
| @@ -186,6 +191,11 @@ func Load(get func(string) string) (Config, error) { | |||
| 186 | 191 | } | |
| 187 | 192 | get = resolveFiles(get, &problems) | |
| 188 | 193 | 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 | 200 | c.Addr = get(EnvAddr) | |
| 191 | 201 | 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 string | 116 | 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 Config | 193 | 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) { | |||
| 360 | 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 | + | } | |
| @@ -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 | + | } | |||