Diff
1diff --git a/README.md b/README.md
2index af2aebe40808cbf0d4c1d530059649907d72ae51..c28415812a7d0072d3120294ab5af026e885bb6d 100644
3--- a/README.md
4+++ b/README.md
5@@ -204,6 +204,34 @@ http:
6 # Make sure to use https:// if you are using TLS.
7 public_url: "http://localhost:23232"
8
9+ # The cross-origin request security options
10+ cors:
11+ # The allowed cross-origin headers
12+ allowed_headers:
13+ - "Accept"
14+ - "Accept-Language"
15+ - "Content-Language"
16+ - "Content-Type"
17+ - "Origin"
18+ - "X-Requested-With"
19+ - "User-Agent"
20+ - "Authorization"
21+ - "Access-Control-Request-Method"
22+ - "Access-Control-Allow-Origin"
23+
24+ # The allowed cross-origin URLs
25+ allowed_origins:
26+ - "http://localhost:23232" # always allowed
27+ # - "https://example.com"
28+
29+ # The allowed cross-origin methods
30+ allowed_methods:
31+ - "GET"
32+ - "HEAD"
33+ - "POST"
34+ - "PUT"
35+ - "OPTIONS"
36+
37 # The database configuration.
38 db:
39 # The database driver to use.
40diff --git a/pkg/config/config.go b/pkg/config/config.go
41index 05dd2c38e3e3f70d6e7facfe526afd8fe0fc4215..6c1355edaf395466cc9203cf6eab915e28d39efa 100644
42--- a/pkg/config/config.go
43+++ b/pkg/config/config.go
44@@ -61,6 +61,15 @@ type GitConfig struct {
45 MaxConnections int `env:"MAX_CONNECTIONS" yaml:"max_connections"`
46 }
47
48+// CORSConfig is the CORS configuration for the server.
49+type CORSConfig struct {
50+ AllowedHeaders []string `env:"ALLOWED_HEADERS" yaml:"allowed_headers"`
51+
52+ AllowedOrigins []string `env:"ALLOWED_ORIGINS" yaml:"allowed_origins"`
53+
54+ AllowedMethods []string `env:"ALLOWED_METHODS" yaml:"allowed_methods"`
55+}
56+
57 // HTTPConfig is the HTTP configuration for the server.
58 type HTTPConfig struct {
59 // Enabled toggles the HTTP server on/off
60@@ -77,6 +86,9 @@ type HTTPConfig struct {
61
62 // PublicURL is the public URL of the HTTP server.
63 PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
64+
65+ // CORS is the cross-origin configuration for the HTTP server.
66+ CORS CORSConfig `envPrefix:"CORS_" yaml:"cors"`
67 }
68
69 // StatsConfig is the configuration for the stats server.
70@@ -196,6 +208,9 @@ func (c *Config) Environ() []string {
71 fmt.Sprintf("SOFT_SERVE_HTTP_TLS_KEY_PATH=%s", c.HTTP.TLSKeyPath),
72 fmt.Sprintf("SOFT_SERVE_HTTP_TLS_CERT_PATH=%s", c.HTTP.TLSCertPath),
73 fmt.Sprintf("SOFT_SERVE_HTTP_PUBLIC_URL=%s", c.HTTP.PublicURL),
74+ fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS=%s", strings.Join(c.HTTP.CORS.AllowedHeaders, ",")),
75+ fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS=%s", strings.Join(c.HTTP.CORS.AllowedOrigins, ",")),
76+ fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS=%s", strings.Join(c.HTTP.CORS.AllowedMethods, ",")),
77 fmt.Sprintf("SOFT_SERVE_STATS_ENABLED=%t", c.Stats.Enabled),
78 fmt.Sprintf("SOFT_SERVE_STATS_LISTEN_ADDR=%s", c.Stats.ListenAddr),
79 fmt.Sprintf("SOFT_SERVE_LOG_FORMAT=%s", c.Log.Format),
80@@ -355,6 +370,11 @@ func DefaultConfig() *Config {
81 Enabled: true,
82 ListenAddr: ":23232",
83 PublicURL: "http://localhost:23232",
84+ CORS: CORSConfig{
85+ AllowedHeaders: []string{"Accept", "Accept-Language", "Content-Language", "Content-Type", "Origin", "X-Requested-With", "User-Agent", "Authorization", "Access-Control-Request-Method", "Access-Control-Allow-Origin"},
86+ AllowedMethods: []string{"GET", "HEAD", "POST", "PUT", "OPTIONS"},
87+ AllowedOrigins: []string{"http://localhost:23232"},
88+ },
89 },
90 Stats: StatsConfig{
91 Enabled: true,
92@@ -423,6 +443,8 @@ func (c *Config) Validate() error {
93
94 c.InitialAdminKeys = pks
95
96+ c.HTTP.CORS.AllowedOrigins = append([]string{c.HTTP.PublicURL}, c.HTTP.CORS.AllowedOrigins...)
97+
98 return nil
99 }
100
101diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
102index 8b84ed8222ba39ca5ad4d1236f5c787b19b0ab00..27c033ccbe253260d7f991a6bc9efc94132c9549 100644
103--- a/pkg/config/config_test.go
104+++ b/pkg/config/config_test.go
105@@ -79,3 +79,48 @@ func TestCustomConfigLocation(t *testing.T) {
106 cfg = DefaultConfig()
107 is.Equal(cfg.Name, "Soft Serve")
108 }
109+
110+func TestParseMultipleHeaders(t *testing.T) {
111+ is := is.New(t)
112+ is.NoErr(os.Setenv("SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS", "Accept,Accept-Language,User-Agent"))
113+ t.Cleanup(func() {
114+ is.NoErr(os.Unsetenv("SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS"))
115+ })
116+ cfg := DefaultConfig()
117+ is.NoErr(cfg.ParseEnv())
118+ is.Equal(cfg.HTTP.CORS.AllowedHeaders, []string{
119+ "Accept",
120+ "Accept-Language",
121+ "User-Agent",
122+ })
123+}
124+
125+func TestParseMultipleOrigins(t *testing.T) {
126+ is := is.New(t)
127+ is.NoErr(os.Setenv("SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS", "http://example.com,https://example.com"))
128+ t.Cleanup(func() {
129+ is.NoErr(os.Unsetenv("SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS"))
130+ })
131+ cfg := DefaultConfig()
132+ is.NoErr(cfg.ParseEnv())
133+ is.Equal(cfg.HTTP.CORS.AllowedOrigins, []string{
134+ "http://localhost:23232",
135+ "http://example.com",
136+ "https://example.com",
137+ })
138+}
139+
140+func TestParseMultipleMethods(t *testing.T) {
141+ is := is.New(t)
142+ is.NoErr(os.Setenv("SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS", "GET,POST,PUT"))
143+ t.Cleanup(func() {
144+ is.NoErr(os.Unsetenv("SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS"))
145+ })
146+ cfg := DefaultConfig()
147+ is.NoErr(cfg.ParseEnv())
148+ is.Equal(cfg.HTTP.CORS.AllowedMethods, []string{
149+ "GET",
150+ "POST",
151+ "PUT",
152+ })
153+}
154diff --git a/pkg/config/file.go b/pkg/config/file.go
155index 55e45c76d75a85da211bf0d9e7113e95f3e4404f..3710069920810a7e4e6364c10447fe9e38e70d4f 100644
156--- a/pkg/config/file.go
157+++ b/pkg/config/file.go
158@@ -89,6 +89,32 @@ http:
159 # Make sure to use https:// if you are using TLS.
160 public_url: "{{ .HTTP.PublicURL }}"
161
162+ # The cross-origin request security options
163+ cors:
164+ # The allowed cross-origin headers
165+ allowed_headers:
166+ - "Accept"
167+ - "Accept-Language"
168+ - "Content-Language"
169+ - "Content-Type"
170+ - "Origin"
171+ - "X-Requested-With"
172+ - "User-Agent"
173+ - "Authorization"
174+ - "Access-Control-Request-Method"
175+ - "Access-Control-Allow-Origin"
176+ # The allowed cross-origin URLs
177+ allowed_origins:
178+ - "{{ .HTTP.PublicURL }}" # always allowed
179+ # - "https://example.com"
180+ # The allowed cross-origin methods
181+ allowed_methods:
182+ - "GET"
183+ - "HEAD"
184+ - "POST"
185+ - "PUT"
186+ - "OPTIONS"
187+
188 # The stats server configuration.
189 stats:
190 # Enable the stats server.
191diff --git a/pkg/web/server.go b/pkg/web/server.go
192index e9d5fb5f3969f8b2722ff7cbb06108c2c7f8106a..9e7be2f3ec695f28e988ff003f34a913d63a12e6 100644
193--- a/pkg/web/server.go
194+++ b/pkg/web/server.go
195@@ -5,6 +5,7 @@ import (
196 "net/http"
197
198 "github.com/charmbracelet/log/v2"
199+ "github.com/charmbracelet/soft-serve/pkg/config"
200 "github.com/gorilla/handlers"
201 "github.com/gorilla/mux"
202 )
203@@ -29,5 +30,12 @@ func NewRouter(ctx context.Context) http.Handler {
204 h = handlers.CompressHandler(h)
205 h = handlers.RecoveryHandler()(h)
206
207+ cfg := config.FromContext(ctx)
208+
209+ h = handlers.CORS(handlers.AllowedHeaders(cfg.HTTP.CORS.AllowedHeaders),
210+ handlers.AllowedOrigins(cfg.HTTP.CORS.AllowedOrigins),
211+ handlers.AllowedMethods(cfg.HTTP.CORS.AllowedMethods),
212+ )(h)
213+
214 return h
215 }
216diff --git a/testscript/testdata/http-cors.txtar b/testscript/testdata/http-cors.txtar
217new file mode 100644
218index 0000000000000000000000000000000000000000..84c50e3466759ba857070eb2203c53877f32491f
219--- /dev/null
220+++ b/testscript/testdata/http-cors.txtar
221@@ -0,0 +1,81 @@
222+# vi: set ft=conf
223+
224+# FIXME: don't skip windows
225+[windows] skip 'curl makes github actions hang'
226+
227+# convert crlf to lf on windows
228+[windows] dos2unix http1.txt http2.txt http3.txt goget.txt gitclone.txt
229+
230+# start soft serve
231+exec soft serve &
232+# wait for SSH server to start
233+ensureserverrunning SSH_PORT
234+
235+# create user
236+soft user create user1 --key "$USER1_AUTHORIZED_KEY"
237+
238+# create access token
239+soft token create --expires-in '1h' 'repo2'
240+cp stdout tokenfile
241+envfile TOKEN=tokenfile
242+soft token create --expires-in '1ns' 'repo2'
243+cp stdout etokenfile
244+envfile ETOKEN=etokenfile
245+usoft token create 'repo2'
246+cp stdout utokenfile
247+envfile UTOKEN=utokenfile
248+
249+# push & create repo with some files, commits, tags...
250+mkdir ./repo2
251+git -c init.defaultBranch=master -C repo2 init
252+mkfile ./repo2/README.md '# Project\nfoo'
253+mkfile ./repo2/foo.png 'foo'
254+mkfile ./repo2/bar.png 'bar'
255+git -C repo2 remote add origin http://$TOKEN@localhost:$HTTP_PORT/repo2
256+git -C repo2 lfs install --local
257+git -C repo2 lfs track '*.png'
258+git -C repo2 add -A
259+git -C repo2 commit -m 'first'
260+git -C repo2 tag v0.1.0
261+git -C repo2 push origin HEAD
262+git -C repo2 push origin HEAD --tags
263+
264+-- test 1 --
265+# default public url is always allowed
266+curl -v --request OPTIONS http://localhost:$HTTP_PORT/repo2/git-upload-pack -H 'Origin: http://localhost:23232' -H 'Access-Control-Request-Method: POST'
267+stderr '.*200 OK.*'
268+
269+# stop the server
270+stopserver
271+
272+-- test 2 --
273+# by default the server does not allow example.com, so the response does not have the "Access-Control-Allow-Origin" header and cors will fail.
274+
275+# restart soft serve
276+exec soft serve &
277+# wait for SSH server to start
278+ensureserverrunning SSH_PORT
279+
280+curl -v --request OPTIONS http://localhost:$HTTP_PORT/repo2/git-upload-pack -H 'Origin: https://example.com' -H 'Access-Control-Request-Method: POST'
281+! stderr '.*Access-Control-Allow-Origin.*'
282+
283+# stop the server
284+stopserver
285+
286+-- test 3 --
287+# allow cross-origin OPTIONS requests for example.com
288+env SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS="https://example.com"
289+env SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS="GET,OPTIONS"
290+env SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS="Origin,Access-Control-Request-Method"
291+
292+# restart soft serve
293+exec soft serve &
294+# wait for SSH server to start
295+ensureserverrunning SSH_PORT
296+
297+curl -v --request OPTIONS http://localhost:$HTTP_PORT/repo2.git/info/refs -H 'Origin: https://example.com' -H 'Access-Control-Request-Method: GET'
298+stderr '.*200 OK.*'
299+
300+# stop the server
301+[windows] stopserver
302+[windows] ! stderr .