Diff
1diff --git a/README.md b/README.md
2index cbefcd78454a8727921dd3365f298e54026f3724..d5bd030a0f82435bd4a8529bacf57ddce76afeb2 100644
3--- a/README.md
4+++ b/README.md
5@@ -158,6 +158,32 @@ ssh:
6 # This is the address that will be used to clone repositories.
7 public_url: "ssh://localhost:23231"
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+ - Origin
17+ # - Content-Type
18+ # - X-Requested-With
19+ # - User-Agent
20+ # - Authorization
21+ # - Access-Control-Request-Method
22+
23+ # The allowed cross-origin URLs
24+ # allowed_origins:
25+ # - *
26+
27+ # The allowed cross-origin methods
28+ allowed_methods:
29+ - GET
30+ - HEAD
31+ - POST
32+ # - PUT
33+ # - OPTIONS
34+
35 # The path to the SSH server's private key.
36 key_path: "ssh/soft_serve_host"
37
38diff --git a/pkg/config/config.go b/pkg/config/config.go
39index 05dd2c38e3e3f70d6e7facfe526afd8fe0fc4215..8db84ebf2763ad9fe15f24948c3268de39738d73 100644
40--- a/pkg/config/config.go
41+++ b/pkg/config/config.go
42@@ -61,6 +61,15 @@ type GitConfig struct {
43 MaxConnections int `env:"MAX_CONNECTIONS" yaml:"max_connections"`
44 }
45
46+// CORSConfig is the CORS configuration for the server.
47+type CORSConfig struct {
48+ AllowedHeaders []string `env:"ALLOWED_HEADERS" yaml:"allowed_headers"`
49+
50+ AllowedOrigins []string `env:"ALLOWED_ORIGINS" yaml:"allowed_origins"`
51+
52+ AllowedMethods []string `env:"ALLOWED_METHODS" yaml:"allowed_methods"`
53+}
54+
55 // HTTPConfig is the HTTP configuration for the server.
56 type HTTPConfig struct {
57 // Enabled toggles the HTTP server on/off
58@@ -77,6 +86,9 @@ type HTTPConfig struct {
59
60 // PublicURL is the public URL of the HTTP server.
61 PublicURL string `env:"PUBLIC_URL" yaml:"public_url"`
62+
63+ // HTTP is the configuration for the HTTP server.
64+ CORS CORSConfig `envPrefix:"CORS_" yaml:"cors"`
65 }
66
67 // StatsConfig is the configuration for the stats server.
68@@ -196,6 +208,9 @@ func (c *Config) Environ() []string {
69 fmt.Sprintf("SOFT_SERVE_HTTP_TLS_KEY_PATH=%s", c.HTTP.TLSKeyPath),
70 fmt.Sprintf("SOFT_SERVE_HTTP_TLS_CERT_PATH=%s", c.HTTP.TLSCertPath),
71 fmt.Sprintf("SOFT_SERVE_HTTP_PUBLIC_URL=%s", c.HTTP.PublicURL),
72+ fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS=%s", strings.Join(c.HTTP.CORS.AllowedHeaders, ",")),
73+ fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS=%s", strings.Join(c.HTTP.CORS.AllowedOrigins, ",")),
74+ fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS=%s", strings.Join(c.HTTP.CORS.AllowedMethods, ",")),
75 fmt.Sprintf("SOFT_SERVE_STATS_ENABLED=%t", c.Stats.Enabled),
76 fmt.Sprintf("SOFT_SERVE_STATS_LISTEN_ADDR=%s", c.Stats.ListenAddr),
77 fmt.Sprintf("SOFT_SERVE_LOG_FORMAT=%s", c.Log.Format),
78diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
79index 8b84ed8222ba39ca5ad4d1236f5c787b19b0ab00..b0653f28241239b14c225f76f247c154d67c82aa 100644
80--- a/pkg/config/config_test.go
81+++ b/pkg/config/config_test.go
82@@ -79,3 +79,47 @@ func TestCustomConfigLocation(t *testing.T) {
83 cfg = DefaultConfig()
84 is.Equal(cfg.Name, "Soft Serve")
85 }
86+
87+func TestParseMultipleHeaders(t *testing.T) {
88+ is := is.New(t)
89+ is.NoErr(os.Setenv("SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS", "Accept,Accept-Language,User-Agent"))
90+ t.Cleanup(func() {
91+ is.NoErr(os.Unsetenv("SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS"))
92+ })
93+ cfg := DefaultConfig()
94+ is.NoErr(cfg.ParseEnv())
95+ is.Equal(cfg.HTTP.CORS.AllowedHeaders, []string{
96+ "Accept",
97+ "Accept-Language",
98+ "User-Agent",
99+ })
100+}
101+
102+func TestParseMultipleOrigins(t *testing.T) {
103+ is := is.New(t)
104+ is.NoErr(os.Setenv("SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS", "https://foo.example,https://foo.example2"))
105+ t.Cleanup(func() {
106+ is.NoErr(os.Unsetenv("SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS"))
107+ })
108+ cfg := DefaultConfig()
109+ is.NoErr(cfg.ParseEnv())
110+ is.Equal(cfg.HTTP.CORS.AllowedOrigins, []string{
111+ "https://foo.example",
112+ "https://foo.example2",
113+ })
114+}
115+
116+func TestParseMultipleMethods(t *testing.T) {
117+ is := is.New(t)
118+ is.NoErr(os.Setenv("SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS", "GET,POST,PUT"))
119+ t.Cleanup(func() {
120+ is.NoErr(os.Unsetenv("SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS"))
121+ })
122+ cfg := DefaultConfig()
123+ is.NoErr(cfg.ParseEnv())
124+ is.Equal(cfg.HTTP.CORS.AllowedMethods, []string{
125+ "GET",
126+ "POST",
127+ "PUT",
128+ })
129+}
130diff --git a/pkg/web/server.go b/pkg/web/server.go
131index 74a04f5b176bee7d1710f023643436119b181c97..ab336e89a5f358be803812b93144a10722af4010 100644
132--- a/pkg/web/server.go
133+++ b/pkg/web/server.go
134@@ -5,6 +5,7 @@ import (
135 "net/http"
136
137 "github.com/charmbracelet/log"
138+ "github.com/charmbracelet/soft-serve/pkg/config"
139 "github.com/gorilla/handlers"
140 "github.com/gorilla/mux"
141 )
142@@ -26,5 +27,12 @@ func NewRouter(ctx context.Context) http.Handler {
143 h = handlers.CompressHandler(h)
144 h = handlers.RecoveryHandler()(h)
145
146+ cfg := config.FromContext(ctx)
147+
148+ h = handlers.CORS(handlers.AllowedHeaders(cfg.HTTP.CORS.AllowedHeaders),
149+ handlers.AllowedOrigins(cfg.HTTP.CORS.AllowedOrigins),
150+ handlers.AllowedMethods(cfg.HTTP.CORS.AllowedMethods),
151+ )(h)
152+
153 return h
154 }
155diff --git a/testscript/testdata/http-cors.txtar b/testscript/testdata/http-cors.txtar
156new file mode 100644
157index 0000000000000000000000000000000000000000..c545ab7a1d908fde301410b90a651d8992aeb9e0
158--- /dev/null
159+++ b/testscript/testdata/http-cors.txtar
160@@ -0,0 +1,64 @@
161+# vi: set ft=conf
162+
163+# FIXME: don't skip windows
164+[windows] skip 'curl makes github actions hang'
165+
166+# convert crlf to lf on windows
167+[windows] dos2unix http1.txt http2.txt http3.txt goget.txt gitclone.txt
168+
169+# start soft serve
170+exec soft serve &
171+# wait for SSH server to start
172+ensureserverrunning SSH_PORT
173+
174+# create user
175+soft user create user1 --key "$USER1_AUTHORIZED_KEY"
176+
177+# create access token
178+soft token create --expires-in '1h' 'repo2'
179+cp stdout tokenfile
180+envfile TOKEN=tokenfile
181+soft token create --expires-in '1ns' 'repo2'
182+cp stdout etokenfile
183+envfile ETOKEN=etokenfile
184+usoft token create 'repo2'
185+cp stdout utokenfile
186+envfile UTOKEN=utokenfile
187+
188+# push & create repo with some files, commits, tags...
189+mkdir ./repo2
190+git -c init.defaultBranch=master -C repo2 init
191+mkfile ./repo2/README.md '# Project\nfoo'
192+mkfile ./repo2/foo.png 'foo'
193+mkfile ./repo2/bar.png 'bar'
194+git -C repo2 remote add origin http://$TOKEN@localhost:$HTTP_PORT/repo2
195+git -C repo2 lfs install --local
196+git -C repo2 lfs track '*.png'
197+git -C repo2 add -A
198+git -C repo2 commit -m 'first'
199+git -C repo2 tag v0.1.0
200+git -C repo2 push origin HEAD
201+git -C repo2 push origin HEAD --tags
202+
203+curl -v --request OPTIONS http://localhost:$HTTP_PORT/repo2.git/info/refs -H 'Origin: https://foo.example' -H 'Access-Control-Request-Method: GET'
204+stderr '.*Method Not Allowed.*'
205+
206+# stop the server
207+stopserver
208+
209+# allow cross-origin OPTIONS requests
210+env SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS="https://foo.example"
211+env SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS="GET,OPTIONS"
212+env SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS="Origin,Access-Control-Request-Method"
213+
214+# restart soft serve
215+exec soft serve &
216+# wait for SSH server to start
217+ensureserverrunning SSH_PORT
218+
219+curl -v --request OPTIONS http://localhost:$HTTP_PORT/repo2.git/info/refs -H 'Origin: https://foo.example' -H 'Access-Control-Request-Method: GET'
220+stderr '.*200 OK.*'
221+
222+# stop the server
223+[windows] stopserver
224+[windows] ! stderr .