From ea8799b5eb7a67d84a43994f11c18733dd593a66 Mon Sep 17 00:00:00 2001 From: fetsorn Date: Thu, 30 Oct 2025 21:31:23 +0300 Subject: [PATCH] feat: add CORS headers (#654) --- 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(+) create mode 100644 testscript/testdata/http-cors.txtar diff --git a/README.md b/README.md index af2aebe40808cbf0d4c1d530059649907d72ae51..c28415812a7d0072d3120294ab5af026e885bb6d 100644 --- a/README.md +++ b/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. diff --git a/pkg/config/config.go b/pkg/config/config.go index 05dd2c38e3e3f70d6e7facfe526afd8fe0fc4215..6c1355edaf395466cc9203cf6eab915e28d39efa 100644 --- a/pkg/config/config.go +++ b/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 } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 8b84ed8222ba39ca5ad4d1236f5c787b19b0ab00..27c033ccbe253260d7f991a6bc9efc94132c9549 100644 --- a/pkg/config/config_test.go +++ b/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", + }) +} diff --git a/pkg/config/file.go b/pkg/config/file.go index 55e45c76d75a85da211bf0d9e7113e95f3e4404f..3710069920810a7e4e6364c10447fe9e38e70d4f 100644 --- a/pkg/config/file.go +++ b/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. diff --git a/pkg/web/server.go b/pkg/web/server.go index e9d5fb5f3969f8b2722ff7cbb06108c2c7f8106a..9e7be2f3ec695f28e988ff003f34a913d63a12e6 100644 --- a/pkg/web/server.go +++ b/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 } diff --git a/testscript/testdata/http-cors.txtar b/testscript/testdata/http-cors.txtar new file mode 100644 index 0000000000000000000000000000000000000000..84c50e3466759ba857070eb2203c53877f32491f --- /dev/null +++ b/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 .