Skip to content

Commit 58e0ef5

Browse files
authored
Merge pull request #281 from roncohen/6.0-CORS-backport
Backport to 6.0: support OPTIONS method needed for preflight browser requests (#244)
2 parents 49a962c + 6d2d1a6 commit 58e0ef5

8 files changed

Lines changed: 140 additions & 3 deletions

File tree

_meta/beat.reference.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,9 @@ apm-server:
2020
#ssl.enabled: false
2121
#ssl.certificate : "path/to/cert"
2222
#ssl.key : "path/to/private_key"
23+
24+
# Comma separated list of permitted origins for frontend. User-agents will send
25+
# a origin header that will be validated against this list.
26+
# An origin is made of a protocol scheme, host and port, without the url path.
27+
# If an item in the list is a single *, everything will be allowed
28+
#allow_origins : *

apm-server.reference.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ apm-server:
2121
#ssl.certificate : "path/to/cert"
2222
#ssl.key : "path/to/private_key"
2323

24+
# Comma separated list of permitted origins for frontend. User-agents will send
25+
# a origin header that will be validated against this list.
26+
# An origin is made of a protocol scheme, host and port, without the url path.
27+
# If an item in the list is a single *, everything will be allowed
28+
#allow_origins : *
29+
2430
#================================ General ======================================
2531

2632
# The name of the shipper that publishes the network data. It can be used to group

beater/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type Config struct {
1515
SSL *SSLConfig `config:"ssl"`
1616
ConcurrentRequests int `config:"concurrent_requests" validate:"min=1"`
1717
EnableFrontend bool `config:"enable_frontend"`
18+
AllowOrigins []string `config:"allow_origins"`
1819
}
1920

2021
type SSLConfig struct {
@@ -36,4 +37,5 @@ var defaultConfig = Config{
3637
WriteTimeout: 2 * time.Second,
3738
ShutdownTimeout: 5 * time.Second,
3839
SecretToken: "",
40+
AllowOrigins: []string{"*"},
3941
}

beater/handlers.go

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ const (
2929
BackendErrorsURL = "/v1/errors"
3030
FrontendErrorsURL = "/v1/client-side/errors"
3131
HealthCheckURL = "/healthcheck"
32+
33+
supportedHeaders = "Content-Type, Content-Encoding, Accept"
34+
supportedMethods = "POST, OPTIONS"
3235
)
3336

3437
type ProcessorFactory func() processor.Processor
@@ -79,7 +82,8 @@ func backendHandler(pf ProcessorFactory, config Config, report reporter) http.Ha
7982
func frontendHandler(pf ProcessorFactory, config Config, report reporter) http.Handler {
8083
return logHandler(
8184
frontendSwitchHandler(config.EnableFrontend,
82-
processRequestHandler(pf, config, report)))
85+
corsHandler(config.AllowOrigins,
86+
processRequestHandler(pf, config, report))))
8387
}
8488

8589
func healthCheckHandler(_ ProcessorFactory, _ Config, _ reporter) http.Handler {
@@ -133,6 +137,55 @@ func isAuthorized(req *http.Request, secretToken string) bool {
133137
return subtle.ConstantTimeCompare([]byte(parts[1]), []byte(secretToken)) == 1
134138
}
135139

140+
func corsHandler(allowedOrigins []string, h http.Handler) http.Handler {
141+
142+
var isAllowed = func(origin string) bool {
143+
for _, allowed := range allowedOrigins {
144+
if origin == allowed || allowed == "*" {
145+
return true
146+
}
147+
}
148+
return false
149+
}
150+
151+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
152+
153+
// origin header is always set by the browser
154+
origin := r.Header.Get("Origin")
155+
validOrigin := isAllowed(origin)
156+
157+
if r.Method == "OPTIONS" {
158+
159+
// setting the ACAO header is the way to tell the browser to go ahead with the request
160+
if validOrigin {
161+
// do not set the configured origin(s), echo the received origin instead
162+
w.Header().Set("Access-Control-Allow-Origin", origin)
163+
}
164+
165+
// tell browsers to cache response requestHeaders for up to 1 hour (browsers might ignore this)
166+
w.Header().Set("Access-Control-Max-Age", "3600")
167+
// origin must be part of the cache key so that we can handle multiple allowed origins
168+
w.Header().Set("Vary", "Origin")
169+
170+
// required if Access-Control-Request-Method and Access-Control-Request-Headers are in the requestHeaders
171+
w.Header().Set("Access-Control-Allow-Methods", supportedMethods)
172+
w.Header().Set("Access-Control-Allow-Headers", supportedHeaders)
173+
174+
w.Header().Set("Content-Length", "0")
175+
176+
sendStatus(w, r, 200, nil)
177+
178+
} else if validOrigin {
179+
// we need to check the origin and set the ACAO header in both the OPTIONS preflight and the actual request
180+
w.Header().Set("Access-Control-Allow-Origin", origin)
181+
h.ServeHTTP(w, r)
182+
183+
} else {
184+
sendStatus(w, r, 403, errForbidden)
185+
}
186+
})
187+
}
188+
136189
func processRequestHandler(pf ProcessorFactory, config Config, report reporter) http.Handler {
137190
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
138191
code, err := processRequest(r, pf, config.MaxUnzippedSize, report)

beater/server_test.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,31 @@ func TestServerFrontendSwitch(t *testing.T) {
7272

7373
rec := httptest.NewRecorder()
7474
apm.Handler.ServeHTTP(rec, req)
75+
apm.Handler = newMuxer(Config{EnableFrontend: false, AllowOrigins: []string{"*"}}, nil)
7576
assert.Equal(t, 403, rec.Code, rec.Body.String())
7677

77-
apm.Handler = newMuxer(Config{EnableFrontend: true}, nil)
78+
apm.Handler = newMuxer(Config{EnableFrontend: true, AllowOrigins: []string{"*"}}, nil)
7879
rec = httptest.NewRecorder()
7980
apm.Handler.ServeHTTP(rec, req)
8081
assert.NotEqual(t, 403, rec.Code, rec.Body.String())
8182
}
8283

84+
func TestServerCORS(t *testing.T) {
85+
apm, teardown := setupServer(t, noSSL)
86+
defer teardown()
87+
88+
req, _ := http.NewRequest("POST", FrontendTransactionsURL, bytes.NewReader(testData))
89+
90+
apm.Handler = newMuxer(Config{EnableFrontend: true,
91+
AllowOrigins: []string{"http://notmydomain.com", "http://neitherthisone.com"}},
92+
nil)
93+
94+
rec := httptest.NewRecorder()
95+
apm.Handler.ServeHTTP(rec, req)
96+
97+
assert.Equal(t, 403, rec.Code, rec.Body.String())
98+
}
99+
83100
func TestServerNoContentType(t *testing.T) {
84101
apm, teardown := setupServer(t, noSSL)
85102
defer teardown()

tests/system/apmserver.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,14 @@ def config(self):
7979
return cfg
8080

8181

82+
class CorsBaseTest(ClientSideBaseTest):
83+
84+
def config(self):
85+
cfg = super(CorsBaseTest, self).config()
86+
cfg.update({"allow_origins": ["http://www.elastic.co"]})
87+
return cfg
88+
89+
8290
class ElasticTest(ServerBaseTest):
8391

8492
@classmethod

tests/system/config/apm-server.yml.j2

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ apm-server:
44
host: "localhost:8200"
55
secret_token: {{ secret_token }}
66
enable_frontend: {{ enable_frontend }}
7+
allow_origins: {{ allow_origins }}
78
ssl.enabled: {{ ssl_enabled }}
89
ssl.certificate: {{ ssl_cert }}
910
ssl.key: {{ ssl_key }}

tests/system/test_requests.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from nose.tools import raises
22

3-
from apmserver import ServerBaseTest, SecureServerBaseTest, ClientSideBaseTest
3+
from apmserver import ServerBaseTest, SecureServerBaseTest, ClientSideBaseTest, CorsBaseTest
44
from requests.exceptions import SSLError
55
import requests
66
import json
@@ -129,3 +129,47 @@ def test_ok(self):
129129
transactions = self.get_transaction_payload()
130130
r = requests.post(self.transactions_url, json=transactions)
131131
assert r.status_code == 202, r.status_code
132+
133+
134+
class CorsTest(CorsBaseTest):
135+
136+
def test_ok(self):
137+
transactions = self.get_transaction_payload()
138+
r = requests.post(self.transactions_url, json=transactions, headers={'Origin': 'http://www.elastic.co'})
139+
assert r.headers['Access-Control-Allow-Origin'] == 'http://www.elastic.co', r.headers
140+
assert r.status_code == 202, r.status_code
141+
142+
def test_bad_origin(self):
143+
# origin must include protocol and match exactly the allowed origin
144+
transactions = self.get_transaction_payload()
145+
r = requests.post(self.transactions_url, json=transactions, headers={'Origin': 'www.elastic.co'})
146+
assert r.status_code == 403, r.status_code
147+
148+
def test_no_origin(self):
149+
transactions = self.get_transaction_payload()
150+
r = requests.post(self.transactions_url, json=transactions)
151+
assert r.status_code == 403, r.status_code
152+
153+
def test_preflight(self):
154+
transactions = self.get_transaction_payload()
155+
r = requests.options(self.transactions_url,
156+
json=transactions,
157+
headers={'Origin': 'http://www.elastic.co',
158+
'Access-Control-Request-Method': 'POST',
159+
'Access-Control-Request-Headers': 'Content-Type, Content-Encoding'})
160+
assert r.status_code == 200, r.status_code
161+
assert r.headers['Access-Control-Allow-Origin'] == 'http://www.elastic.co', r.headers
162+
assert r.headers['Access-Control-Allow-Headers'] == 'Content-Type, Content-Encoding, Accept', r.headers
163+
assert r.headers['Access-Control-Allow-Methods'] == 'POST, OPTIONS', r.headers
164+
assert r.headers['Vary'] == 'Origin', r.headers
165+
assert r.headers['Content-Length'] == '0', r.headers
166+
assert r.headers['Access-Control-Max-Age'] == '3600', r.headers
167+
168+
def test_preflight_bad_headers(self):
169+
transactions = self.get_transaction_payload()
170+
for h in [{'Access-Control-Request-Method': 'POST'}, {'Origin': 'www.elastic.co'}]:
171+
r = requests.options(self.transactions_url, json=transactions, headers=h)
172+
assert r.status_code == 200, r.status_code
173+
assert 'Access-Control-Allow-Origin' not in r.headers.keys(), r.headers
174+
assert r.headers['Access-Control-Allow-Headers'] == 'Content-Type, Content-Encoding, Accept', r.headers
175+
assert r.headers['Access-Control-Allow-Methods'] == 'POST, OPTIONS', r.headers

0 commit comments

Comments
 (0)