d2c2ff5f6d1a1e40e10b323301b2cb93e58e6795
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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'
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&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