feat: add CORS headers (#654)

fetsorn created

Change summary

README.md                           | 28 ++++++++++
pkg/config/config.go                | 22 ++++++++
pkg/config/config_test.go           | 45 +++++++++++++++++
pkg/config/file.go                  | 26 +++++++++
pkg/web/server.go                   |  8 +++
testscript/testdata/http-cors.txtar | 81 +++++++++++++++++++++++++++++++
6 files changed, 210 insertions(+)

Detailed changes

README.md 🔗

@@ -204,6 +204,34 @@ http:
   # Make sure to use https:// if you are using TLS.
   public_url: "http://localhost:23232"
 
+  # The cross-origin request security options
+  cors:
+    # The allowed cross-origin headers
+    allowed_headers:
+      - "Accept"
+      - "Accept-Language"
+      - "Content-Language"
+      - "Content-Type"
+      - "Origin"
+      - "X-Requested-With"
+      - "User-Agent"
+      - "Authorization"
+      - "Access-Control-Request-Method"
+      - "Access-Control-Allow-Origin"
+
+    # The allowed cross-origin URLs
+    allowed_origins:
+      - "http://localhost:23232" # always allowed
+      # - "https://example.com"
+
+    # The allowed cross-origin methods
+    allowed_methods:
+      - "GET"
+      - "HEAD"
+      - "POST"
+      - "PUT"
+      - "OPTIONS"
+
 # The database configuration.
 db:
   # The database driver to use.

pkg/config/config.go 🔗

@@ -61,6 +61,15 @@ type GitConfig struct {
 	MaxConnections int `env:"MAX_CONNECTIONS" yaml:"max_connections"`
 }
 
+// CORSConfig is the CORS configuration for the server.
+type CORSConfig struct {
+	AllowedHeaders []string `env:"ALLOWED_HEADERS" yaml:"allowed_headers"`
+
+	AllowedOrigins []string `env:"ALLOWED_ORIGINS" yaml:"allowed_origins"`
+
+	AllowedMethods []string `env:"ALLOWED_METHODS" yaml:"allowed_methods"`
+}
+
 // HTTPConfig is the HTTP configuration for the server.
 type HTTPConfig struct {
 	// Enabled toggles the HTTP server on/off
@@ -77,6 +86,9 @@ type HTTPConfig struct {
 
 	// PublicURL is the public URL of the HTTP server.
 	PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
+
+	// CORS is the cross-origin configuration for the HTTP server.
+	CORS CORSConfig `envPrefix:"CORS_" yaml:"cors"`
 }
 
 // StatsConfig is the configuration for the stats server.
@@ -196,6 +208,9 @@ func (c *Config) Environ() []string {
 		fmt.Sprintf("SOFT_SERVE_HTTP_TLS_KEY_PATH=%s", c.HTTP.TLSKeyPath),
 		fmt.Sprintf("SOFT_SERVE_HTTP_TLS_CERT_PATH=%s", c.HTTP.TLSCertPath),
 		fmt.Sprintf("SOFT_SERVE_HTTP_PUBLIC_URL=%s", c.HTTP.PublicURL),
+		fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS=%s", strings.Join(c.HTTP.CORS.AllowedHeaders, ",")),
+		fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS=%s", strings.Join(c.HTTP.CORS.AllowedOrigins, ",")),
+		fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS=%s", strings.Join(c.HTTP.CORS.AllowedMethods, ",")),
 		fmt.Sprintf("SOFT_SERVE_STATS_ENABLED=%t", c.Stats.Enabled),
 		fmt.Sprintf("SOFT_SERVE_STATS_LISTEN_ADDR=%s", c.Stats.ListenAddr),
 		fmt.Sprintf("SOFT_SERVE_LOG_FORMAT=%s", c.Log.Format),
@@ -355,6 +370,11 @@ func DefaultConfig() *Config {
 			Enabled:    true,
 			ListenAddr: ":23232",
 			PublicURL:  "http://localhost:23232",
+			CORS: CORSConfig{
+				AllowedHeaders: []string{"Accept", "Accept-Language", "Content-Language", "Content-Type", "Origin", "X-Requested-With", "User-Agent", "Authorization", "Access-Control-Request-Method", "Access-Control-Allow-Origin"},
+				AllowedMethods: []string{"GET", "HEAD", "POST", "PUT", "OPTIONS"},
+				AllowedOrigins: []string{"http://localhost:23232"},
+			},
 		},
 		Stats: StatsConfig{
 			Enabled:    true,
@@ -423,6 +443,8 @@ func (c *Config) Validate() error {
 
 	c.InitialAdminKeys = pks
 
+	c.HTTP.CORS.AllowedOrigins = append([]string{c.HTTP.PublicURL}, c.HTTP.CORS.AllowedOrigins...)
+
 	return nil
 }
 

pkg/config/config_test.go 🔗

@@ -79,3 +79,48 @@ func TestCustomConfigLocation(t *testing.T) {
 	cfg = DefaultConfig()
 	is.Equal(cfg.Name, "Soft Serve")
 }
+
+func TestParseMultipleHeaders(t *testing.T) {
+	is := is.New(t)
+	is.NoErr(os.Setenv("SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS", "Accept,Accept-Language,User-Agent"))
+	t.Cleanup(func() {
+		is.NoErr(os.Unsetenv("SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS"))
+	})
+	cfg := DefaultConfig()
+	is.NoErr(cfg.ParseEnv())
+	is.Equal(cfg.HTTP.CORS.AllowedHeaders, []string{
+		"Accept",
+		"Accept-Language",
+		"User-Agent",
+	})
+}
+
+func TestParseMultipleOrigins(t *testing.T) {
+	is := is.New(t)
+	is.NoErr(os.Setenv("SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS", "http://example.com,https://example.com"))
+	t.Cleanup(func() {
+		is.NoErr(os.Unsetenv("SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS"))
+	})
+	cfg := DefaultConfig()
+	is.NoErr(cfg.ParseEnv())
+	is.Equal(cfg.HTTP.CORS.AllowedOrigins, []string{
+		"http://localhost:23232",
+		"http://example.com",
+		"https://example.com",
+	})
+}
+
+func TestParseMultipleMethods(t *testing.T) {
+	is := is.New(t)
+	is.NoErr(os.Setenv("SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS", "GET,POST,PUT"))
+	t.Cleanup(func() {
+		is.NoErr(os.Unsetenv("SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS"))
+	})
+	cfg := DefaultConfig()
+	is.NoErr(cfg.ParseEnv())
+	is.Equal(cfg.HTTP.CORS.AllowedMethods, []string{
+		"GET",
+		"POST",
+		"PUT",
+	})
+}

pkg/config/file.go 🔗

@@ -89,6 +89,32 @@ http:
   # Make sure to use https:// if you are using TLS.
   public_url: "{{ .HTTP.PublicURL }}"
 
+  # The cross-origin request security options
+  cors:
+    # The allowed cross-origin headers
+    allowed_headers:
+       - "Accept"
+       - "Accept-Language"
+       - "Content-Language"
+       - "Content-Type"
+       - "Origin"
+       - "X-Requested-With"
+       - "User-Agent"
+       - "Authorization"
+       - "Access-Control-Request-Method"
+       - "Access-Control-Allow-Origin"
+    # The allowed cross-origin URLs
+    allowed_origins:
+       - "{{ .HTTP.PublicURL }}" # always allowed
+       # - "https://example.com"
+    # The allowed cross-origin methods
+    allowed_methods:
+       - "GET"
+       - "HEAD"
+       - "POST"
+       - "PUT"
+       - "OPTIONS"
+
 # The stats server configuration.
 stats:
   # Enable the stats server.

pkg/web/server.go 🔗

@@ -5,6 +5,7 @@ import (
 	"net/http"
 
 	"github.com/charmbracelet/log/v2"
+	"github.com/charmbracelet/soft-serve/pkg/config"
 	"github.com/gorilla/handlers"
 	"github.com/gorilla/mux"
 )
@@ -29,5 +30,12 @@ func NewRouter(ctx context.Context) http.Handler {
 	h = handlers.CompressHandler(h)
 	h = handlers.RecoveryHandler()(h)
 
+	cfg := config.FromContext(ctx)
+
+	h = handlers.CORS(handlers.AllowedHeaders(cfg.HTTP.CORS.AllowedHeaders),
+		handlers.AllowedOrigins(cfg.HTTP.CORS.AllowedOrigins),
+		handlers.AllowedMethods(cfg.HTTP.CORS.AllowedMethods),
+	)(h)
+
 	return h
 }

testscript/testdata/http-cors.txtar 🔗

@@ -0,0 +1,81 @@
+# vi: set ft=conf
+
+# FIXME: don't skip windows
+[windows] skip 'curl makes github actions hang'
+
+# convert crlf to lf on windows
+[windows] dos2unix http1.txt http2.txt http3.txt goget.txt gitclone.txt
+
+# start soft serve
+exec soft serve &
+# wait for SSH server to start
+ensureserverrunning SSH_PORT
+
+# create user
+soft user create user1 --key "$USER1_AUTHORIZED_KEY"
+
+# create access token
+soft token create --expires-in '1h' 'repo2'
+cp stdout tokenfile
+envfile TOKEN=tokenfile
+soft token create --expires-in '1ns' 'repo2'
+cp stdout etokenfile
+envfile ETOKEN=etokenfile
+usoft token create 'repo2'
+cp stdout utokenfile
+envfile UTOKEN=utokenfile
+
+# push & create repo with some files, commits, tags...
+mkdir ./repo2
+git -c init.defaultBranch=master -C repo2 init
+mkfile ./repo2/README.md '# Project\nfoo'
+mkfile ./repo2/foo.png 'foo'
+mkfile ./repo2/bar.png 'bar'
+git -C repo2 remote add origin http://$TOKEN@localhost:$HTTP_PORT/repo2
+git -C repo2 lfs install --local
+git -C repo2 lfs track '*.png'
+git -C repo2 add -A
+git -C repo2 commit -m 'first'
+git -C repo2 tag v0.1.0
+git -C repo2 push origin HEAD
+git -C repo2 push origin HEAD --tags
+
+-- test 1 --
+# default public url is always allowed
+curl -v --request OPTIONS http://localhost:$HTTP_PORT/repo2/git-upload-pack -H 'Origin: http://localhost:23232' -H 'Access-Control-Request-Method: POST'
+stderr '.*200 OK.*'
+
+# stop the server
+stopserver
+
+-- test 2 --
+# 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.
+
+# restart soft serve
+exec soft serve &
+# wait for SSH server to start
+ensureserverrunning SSH_PORT
+
+curl -v --request OPTIONS http://localhost:$HTTP_PORT/repo2/git-upload-pack -H 'Origin: https://example.com' -H 'Access-Control-Request-Method: POST'
+! stderr '.*Access-Control-Allow-Origin.*'
+
+# stop the server
+stopserver
+
+-- test 3 --
+# allow cross-origin OPTIONS requests for example.com
+env SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS="https://example.com"
+env SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS="GET,OPTIONS"
+env SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS="Origin,Access-Control-Request-Method"
+
+# restart soft serve
+exec soft serve &
+# wait for SSH server to start
+ensureserverrunning SSH_PORT
+
+curl -v --request OPTIONS http://localhost:$HTTP_PORT/repo2.git/info/refs -H 'Origin: https://example.com' -H 'Access-Control-Request-Method: GET'
+stderr '.*200 OK.*'
+
+# stop the server
+[windows] stopserver
+[windows] ! stderr .