ea8799b5eb7a67d84a43994f11c18733dd593a66

Author
fetsorn <fetsorn@gmail.com>
Committer
GitHub <noreply@github.com>
Date

Message

feat: add CORS headers (#654)

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 .