d2c2ff5f6d1a1e40e10b323301b2cb93e58e6795

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

test(web): cover and document public UI

Diff

This diff is truncated to protect this page.

  1diff --git a/README.md b/README.md
  2index 9968b6f50097f5cb009e1f7f485af53a0c09242c..ad73e578c92182580094fc426d9cf885d9226cd6 100644
  3--- a/README.md
  4+++ b/README.md
  5@@ -222,7 +222,7 @@ http:
  6   # Make sure to use https:// if you are using TLS.
  7   public_url: "http://localhost:23232"
  8 
  9-  # Enable the public web UI.
 10+  # Enable the read-only public web UI. Disabled by default.
 11   web_ui:
 12     enabled: false
 13 
 14@@ -306,12 +306,37 @@ name all in uppercase. Here are some examples:
 15 - `SOFT_SERVE_SSH_KEY_PATH`: SSH host key-pair path
 16 - `SOFT_SERVE_HTTP_LISTEN_ADDR`: HTTP listen address
 17 - `SOFT_SERVE_HTTP_PUBLIC_URL`: HTTP public URL used for cloning
 18-- `SOFT_SERVE_HTTP_WEB_UI_ENABLED`: Enable the public web UI
 19+- `SOFT_SERVE_HTTP_WEB_UI_ENABLED`: Enable the read-only public web UI
 20 - `SOFT_SERVE_GIT_MAX_CONNECTIONS`: The number of simultaneous connections to git daemon
 21 - `SOFT_SERVE_ANON_ACCESS`: Overrides the `anon-access` setting (see [Authentication](#authentication))
 22 - `SOFT_SERVE_ALLOW_KEYLESS`: Overrides the `allow-keyless` setting (see [Authentication](#authentication))
 23 - `SOFT_SERVE_DEFAULT_REPO`: Repository name to create on boot if missing
 24 
 25+#### Public Web UI
 26+
 27+Soft Serve includes an embedded, read-only web UI in the `soft` binary; it does
 28+not require a separate service or frontend build. It is disabled by default.
 29+Enable it in `config.yaml` with `http.web_ui.enabled: true`, or set
 30+`SOFT_SERVE_HTTP_WEB_UI_ENABLED=true`.
 31+
 32+The UI is public-only: it does not provide browser login, sessions, management
 33+forms, or write operations. It serves the homepage (`/`), nested repository
 34+overviews (`/{repo}`), file trees and source (`/{repo}/@/tree?ref=&path=`),
 35+downloads (`/{repo}/@/raw?ref=&path=`), commit history
 36+(`/{repo}/@/commits?ref=&page=`), commit details (`/{repo}/@/commit?hash=`),
 37+and branches and tags (`/{repo}/@/refs`). Repository names may be nested, such
 38+as `team/project`.
 39+
 40+The homepage lists public, non-hidden repositories. Private repositories are
 41+inaccessible through the UI, while hidden public repositories remain directly
 42+browsable but are omitted from the homepage. A public-readable `.soft-serve`
 43+repository is also omitted from the list; its README is rendered as optional
 44+homepage profile content.
 45+
 46+When serving through a reverse proxy or TLS terminator, set `http.public_url`
 47+to the external canonical URL (including `https://`). Soft Serve uses that URL
 48+for HTTP clone commands and Go import metadata.
 49+
 50 #### Database Configuration
 51 
 52diff --git a/pkg/config/file.go b/pkg/config/file.go
 53index ad5950308bbfec56dc0fbaf94c79fbae728119b2..5cd3698a9278804384b8c131f9dbdcaadae338fb 100644
 54--- a/pkg/config/file.go
 55+++ b/pkg/config/file.go
 56@@ -89,7 +89,7 @@ http:
 57   # Make sure to use https:// if you are using TLS.
 58   public_url: "{{ .HTTP.PublicURL }}"
 59 
 60-  # Enable the public web UI.
 61+  # Enable the read-only public web UI. Disabled by default.
 62   web_ui:
 63     enabled: {{ .HTTP.WebUI.Enabled }}
 64 
 65diff --git a/pkg/web/pages/browser.go b/pkg/web/pages/browser.go
 66index 79820c61b0d38e114a3ab64e6fd116be2ce03170..6b03bc1a2ac5fa44c92c49c51d896021638dd9b3 100644
 67--- a/pkg/web/pages/browser.go
 68+++ b/pkg/web/pages/browser.go
 69@@ -219,7 +219,7 @@ func rawFile(w http.ResponseWriter, r *http.Request) {
 70 		return
 71 	}
 72 	metadata := RawFileMetadata(entry.Name())
 73-	w.Header().Set("X-Content-Type-Options", "nosniff")
 74+	SetContentSafetyHeaders(w)
 75 	w.Header().Set("Content-Type", metadata.ContentType)
 76 	if metadata.ContentDisposition != "" {
 77 		w.Header().Set("Content-Disposition", metadata.ContentDisposition)
 78diff --git a/pkg/web/pages/controller.go b/pkg/web/pages/controller.go
 79index 332b8ef0f5f9dbf7295569aa3f59f8ced75f356c..478456fd32041a50c15cbb5997ac075eabdf4125 100644
 80--- a/pkg/web/pages/controller.go
 81+++ b/pkg/web/pages/controller.go
 82@@ -125,6 +125,7 @@ func loadProfile(ctx context.Context) template.HTML {
 83 }
 84 
 85 func stylesheet(w http.ResponseWriter, r *http.Request) {
 86+	SetContentSafetyHeaders(w)
 87 	css, err := fs.ReadFile(files, "assets/site.css")
 88 	if err != nil {
 89 		http.Error(w, "embedded stylesheet is unavailable", http.StatusInternalServerError)
 90diff --git a/pkg/web/pages/render.go b/pkg/web/pages/render.go
 91index b7109de4daea8ea1b5e82b38c72d46b5296b087f..d984c35db7fd261a1cd508e204377158b2cf7f90 100644
 92--- a/pkg/web/pages/render.go
 93+++ b/pkg/web/pages/render.go
 94@@ -15,11 +15,16 @@ import (
 95 	"github.com/yuin/goldmark/text"
 96 )
 97 
 98+// SetContentSafetyHeaders applies response headers that are safe for pages and assets.
 99+func SetContentSafetyHeaders(w http.ResponseWriter) {
100+	w.Header().Set("X-Content-Type-Options", "nosniff")
101+	w.Header().Set("Referrer-Policy", "same-origin")
102+}
103+
104 // SetSecurityHeaders applies the common security policy for dynamic page responses.
105 func SetSecurityHeaders(w http.ResponseWriter) {
106+	SetContentSafetyHeaders(w)
107 	w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
108-	w.Header().Set("X-Content-Type-Options", "nosniff")
109-	w.Header().Set("Referrer-Policy", "same-origin")
110 }
111 
112 // RenderMarkdown renders Markdown with raw HTML disabled and sanitizes the result.
113diff --git a/pkg/web/repository_browser_test.go b/pkg/web/repository_browser_test.go
114index b40554f067f2204836ee78053553b24d6af500d0..a5d512e1171d3d40991cdd2532ea664272b5d8f1 100644
115--- a/pkg/web/repository_browser_test.go
116+++ b/pkg/web/repository_browser_test.go
117@@ -128,7 +128,7 @@ func TestRepositoryBrowserOverviewAndTreeRoutes(t *testing.T) {
118 	}
119 
120 	png := request("/team/nested/@/raw?ref=refs%2Fheads%2Fmain&path=images%2Ficon.png")
121-	if png.Code != http.StatusOK || png.Header().Get("Content-Type") != "image/png" || png.Header().Get("Content-Disposition") != "" || png.Header().Get("X-Content-Type-Options") != "nosniff" {
122+	if png.Code != http.StatusOK || png.Header().Get("Content-Type") != "image/png" || png.Header().Get("Content-Disposition") != "" || png.Header().Get("X-Content-Type-Options") != "nosniff" || png.Header().Get("Referrer-Policy") != "same-origin" {
123 		t.Errorf("PNG response = %d, headers = %#v", png.Code, png.Header())
124 	}
125 	for _, name := range []string{"unsafe.svg", "unsafe.html", "unknown.xyz"} {
126diff --git a/pkg/web/server_test.go b/pkg/web/server_test.go
127index c0b3ca47e18cd30b8526365fe76aa69fccdf4239..4c303d904d59741468abea6d6850e35cc1567bdd 100644
128--- a/pkg/web/server_test.go
129+++ b/pkg/web/server_test.go
130@@ -34,6 +34,9 @@ func TestRouterWebUIShellIsOptIn(t *testing.T) {
131 	router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/@/assets/site.css", nil))
132 	is.Equal(w.Code, http.StatusOK)
133 	is.Equal(w.Header().Get("Content-Type"), "text/css; charset=utf-8")
134+	is.Equal(w.Header().Get("Cache-Control"), "public, max-age=31536000")
135+	is.Equal(w.Header().Get("X-Content-Type-Options"), "nosniff")
136+	is.Equal(w.Header().Get("Referrer-Policy"), "same-origin")
137 	is.True(strings.Contains(w.Body.String(), "font-family"))
138 }
139 
140diff --git a/testscript/testdata/web-ui.txtar b/testscript/testdata/web-ui.txtar
141new file mode 100644
142index 0000000000000000000000000000000000000000..df5b9ddd52aea3cec6b4b8500b4a5ccaa311c811
143--- /dev/null
144+++ b/testscript/testdata/web-ui.txtar
145@@ -0,0 +1,127 @@
146+# vi: set ft=conf
147+
148+# FIXME: curl makes GitHub Actions hang on Windows.
149+[windows] skip 'curl makes github actions hang'
150+
151+# The UI is opt-in and must coexist with all HTTP infrastructure routes.
152+env SOFT_SERVE_HTTP_WEB_UI_ENABLED=true
153+exec soft serve &
154+ensureserverrunning SSH_PORT
155+ensureserverrunning HTTP_PORT
156+
157+# Build the required public, private, hidden, nested, empty, and profile data.
158+soft repo create public
159+soft repo create private
160+soft repo private private true
161+soft repo create hidden
162+soft repo hidden hidden true
163+soft repo create team/nested
164+soft repo create empty
165+soft repo create .soft-serve
166+
167+mkdir nested
168+# Use a known default ref for stable page URLs.
169+git -c init.defaultBranch=main -C nested init
170+mkfile ./nested/README.md '# Nested project\n\n![Logo](images/logo.png)'
171+mkfile ./nested/hello.txt 'hello from source'
172+mkdir nested/images
173+mkfile ./nested/images/logo.png 'not a real PNG, but safe media routing is extension based'
174+git -C nested lfs install --local
175+git -C nested lfs track '*.png'
176+git -C nested add -A
177+git -C nested commit -m 'initial nested project'
178+git -C nested tag v1.0.0
179+git -C nested remote add origin ssh://localhost:$SSH_PORT/team/nested
180+git -C nested push origin HEAD --tags
181+
182+mkdir profile
183+git -c init.defaultBranch=main -C profile init
184+mkfile ./profile/README.md '# Profile\n\nPublic profile content'
185+git -C profile add -A
186+git -C profile commit -m 'profile README'
187+git -C profile remote add origin ssh://localhost:$SSH_PORT/.soft-serve
188+git -C profile push origin HEAD
189+
190+# The homepage lists only visible public cards and renders the profile README.
191+curl http://localhost:$HTTP_PORT/
192+stdout 'public'
193+stdout 'team/nested'
194+stdout 'Public profile content'
195+! stdout 'private'
196+! stdout 'hidden'
197+! stdout '.soft-serve'
198+
199+# Nested overview, tree, source, raw, history, commit, and refs routes work.
200+curl http://localhost:$HTTP_PORT/team/nested
201+stdout 'Nested project'
202+curl http://localhost:$HTTP_PORT/team/nested/@/tree
203+stdout 'README.md'
204+stdout 'hello.txt'
205+curl http://localhost:$HTTP_PORT/team/nested/@/tree?path=hello.txt
206+stdout 'hello from source'
207+curl http://localhost:$HTTP_PORT/team/nested
208+stdout '/team/nested/@/raw[?]path=images%2Flogo.png&amp;ref=refs%2Fheads%2Fmain'
209+curl -v http://localhost:$HTTP_PORT/team/nested/@/raw?path=images%2Flogo.png
210+stderr '.*200 OK.*'
211+stderr '.*Content-Type: image/png.*'
212+stderr '.*X-Content-Type-Options: nosniff.*'
213+stderr '.*Referrer-Policy: same-origin.*'
214+curl http://localhost:$HTTP_PORT/team/nested/@/commits
215+stdout 'initial nested project'
216+git -C nested rev-parse HEAD
217+cp stdout hash
218+envfile HASH=hash
219+curl http://localhost:$HTTP_PORT/team/nested/@/commit?hash=$HASH
220+stdout 'initial nested project'
221+curl http://localhost:$HTTP_PORT/team/nested/@/refs
222+stdout 'v1.0.0'
223+
224+# Empty repositories render, hidden public repositories remain directly readable,
225+# and private routes return 404 without content disclosure.
226+curl http://localhost:$HTTP_PORT/empty
227+stdout 'This repository is empty.'
228+curl http://localhost:$HTTP_PORT/hidden
229+stdout 'This repository is empty.'
230+curl -v http://localhost:$HTTP_PORT/private
231+stderr '.*404 Not Found.*'
232+! stdout 'private'
233+curl -v http://localhost:$HTTP_PORT/private/@/tree
234+stderr '.*404 Not Found.*'
235+! stdout 'private'
236+
237+# Existing anonymous HTTP fetch, authenticated push, LFS transfer, go-get, and
238+# health routes continue to work while the UI is enabled.
239+git clone http://localhost:$HTTP_PORT/team/nested.git nested-http
240+exists nested-http/images/logo.png
241+soft token create --expires-in '1h' 'nested'
242+stdout 'ss_*'
243+cp stdout tokenfile
244+envfile TOKEN=tokenfile