Skip to content

Commit 6e6bb13

Browse files
authored
Merge pull request #47 from geoadmin/bug-BGDIINF_SB-2442-fix-origin-check
BGDIINF_SB-2442: Fixed origin check - #patch
2 parents aa6d309 + 2aa1606 commit 6e6bb13

File tree

4 files changed

+86
-24
lines changed

4 files changed

+86
-24
lines changed

app/__init__.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
app.config.from_object(settings)
2424

2525

26+
def is_domain_allowed(domain):
27+
return re.match(ALLOWED_DOMAINS_PATTERN, domain) is not None
28+
29+
2630
# NOTE it is better to have this method registered first (before validate_origin) otherwise
2731
# the route might not be logged if another method reject the request.
2832
@app.before_request
@@ -34,12 +38,35 @@ def log_route():
3438
# Reject request from non allowed origins
3539
@app.before_request
3640
def validate_origin():
37-
if 'Origin' not in request.headers:
38-
logger.error('Origin header is not set')
39-
abort(403, 'Not allowed')
40-
if not re.match(ALLOWED_DOMAINS_PATTERN, request.headers['Origin']):
41-
logger.error('Origin=%s is not allowed', request.headers['Origin'])
42-
abort(403, 'Not allowed')
41+
# The Origin header is automatically set by the browser and cannot be changed by the javascript
42+
# application. Unfortunately this header is only set if the request comes from another origin.
43+
# Sec-Fetch-Site header is set to `same-origin` by most of the browsers except by Safari!
44+
# The best protection would be to use the Sec-Fetch-Site and Origin header, however this is
45+
# not supported by Safari. Therefore we added a fallback to the Referer header for Safari.
46+
sec_fetch_site = request.headers.get('Sec-Fetch-Site', None)
47+
origin = request.headers.get('Origin', None)
48+
referrer = request.headers.get('Referer', None)
49+
50+
if origin is not None:
51+
if is_domain_allowed(origin):
52+
return
53+
logger.error('Origin=%s does not match %s', origin, ALLOWED_DOMAINS_PATTERN)
54+
abort(403, 'Permission denied')
55+
56+
if sec_fetch_site is not None:
57+
if sec_fetch_site in ['same-origin', 'same-site']:
58+
return
59+
logger.error('Sec-Fetch-Site=%s is not allowed', sec_fetch_site)
60+
abort(403, 'Permission denied')
61+
62+
if referrer is not None:
63+
if is_domain_allowed(referrer):
64+
return
65+
logger.error('Referer=%s does not match %s', referrer, ALLOWED_DOMAINS_PATTERN)
66+
abort(403, 'Permission denied')
67+
68+
logger.error('Referer and/or Origin and/or Sec-Fetch-Site headers not set')
69+
abort(403, 'Permission denied')
4370

4471

4572
# Add CORS Headers to all request
@@ -64,10 +91,9 @@ def log_response(response):
6491
request.path,
6592
response.status,
6693
extra={
67-
'response':
68-
{
69-
"status_code": response.status_code, "headers": dict(response.headers.items())
70-
},
94+
'response': {
95+
"status_code": response.status_code, "headers": dict(response.headers.items())
96+
},
7197
"duration": time.time() - g.get('request_started', time.time())
7298
}
7399
)

app/settings.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
raise RuntimeError("Environment variable $ALLOWED_DOMAINS was not set")
2525

2626
ALLOWED_DOMAINS = ALLOWED_DOMAINS_STRING.split(',')
27+
ALLOWED_DOMAINS_PATTERN = f"({'|'.join(ALLOWED_DOMAINS)})"
2728

2829
# Proxy settings
2930
FORWARED_ALLOW_IPS = os.getenv('FORWARED_ALLOW_IPS', '*')
@@ -32,4 +33,4 @@
3233
if WSGI_WORKERS <= 0:
3334
from multiprocessing import cpu_count
3435
WSGI_WORKERS = (cpu_count() * 2) + 1
35-
WSGI_TIMEOUT = int(os.getenv('WSGI_TIMEOUT', '3'))
36+
WSGI_TIMEOUT = int(os.getenv('WSGI_TIMEOUT', '3'))

app/version.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,16 @@
1010
# the tag is directly related to the commit or has an additional
1111
# suffix 'v[0-9]+\.[0-9]+\.[0-9]+-beta.[0-9]-[0-9]+-gHASH' denoting
1212
# the 'distance' to the latest tag
13-
with subprocess.Popen(
14-
["git", "describe", "--tags"], stdout=subprocess.PIPE, stderr=subprocess.PIPE
15-
) as proc:
13+
with subprocess.Popen(["git", "describe", "--tags"], stdout=subprocess.PIPE,
14+
stderr=subprocess.PIPE) as proc:
1615
stdout, stderr = proc.communicate()
1716
GIT_VERSION = stdout.decode('utf-8').strip()
1817
if GIT_VERSION == '':
1918
# If theres no git tag found in the history we simply use the short
2019
# version of the latest git commit hash
21-
with subprocess.Popen(
22-
["git", "rev-parse", "--short", "HEAD"], stdout=subprocess.PIPE, stderr=subprocess.PIPE
23-
) as proc:
20+
with subprocess.Popen(["git", "rev-parse", "--short", "HEAD"],
21+
stdout=subprocess.PIPE,
22+
stderr=subprocess.PIPE) as proc:
2423
stdout, stderr = proc.communicate()
2524
APP_VERSION = f"v_{stdout.decode('utf-8').strip()}"
2625
else:

tests/unit_tests/test_route.py

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import logging
22
import unittest
33

4+
from nose2.tools import params
5+
46
from flask import url_for
57

68
from app import app
@@ -16,13 +18,7 @@ def setUp(self):
1618
self.context.push()
1719
self.app = app.test_client()
1820
self.app.testing = True
19-
self.origin_headers = {
20-
"allowed": {
21-
"Origin": "some_random_domain"
22-
}, "bad": {
23-
"Origin": "big-bad-wolf.com"
24-
}
25-
}
21+
self.origin_headers = {"allowed": {"Origin": "some_random_domain"}}
2622

2723
def test_checker(self):
2824
response = self.app.get(url_for('checker'), headers=self.origin_headers["allowed"])
@@ -35,3 +31,43 @@ def test_checker_non_allowed_origin(self):
3531
self.assertEqual(response.status_code, 403)
3632
self.assertEqual(response.content_type, "application/json")
3733
self.assertEqual(response.json["error"]["message"], "Not allowed")
34+
35+
@params(
36+
None,
37+
{'Origin': 'www.example'},
38+
{
39+
'Origin': 'www.example', 'Sec-Fetch-Site': 'cross-site'
40+
},
41+
{
42+
'Origin': 'www.example', 'Sec-Fetch-Site': 'same-site'
43+
},
44+
{
45+
'Origin': 'www.example', 'Sec-Fetch-Site': 'same-origin'
46+
},
47+
{'Referer': 'http://www.example'},
48+
)
49+
def test_feedback_non_allowed_origin(self, headers):
50+
response = self.app.get(url_for('checker'), headers=headers)
51+
self.assertEqual(response.status_code, 403)
52+
self.assertEqual(response.content_type, "application/json")
53+
self.assertEqual(response.json["error"]["message"], "Not allowed")
54+
55+
56+
@params(
57+
{'Origin': 'map.geo.admin.ch'},
58+
{
59+
'Origin': 'map.geo.admin.ch', 'Sec-Fetch-Site': 'same-site'
60+
},
61+
{
62+
'Origin': 'public.geo.admin.ch', 'Sec-Fetch-Site': 'same-origin'
63+
},
64+
{
65+
'Origin': 'http://localhost', 'Sec-Fetch-Site': 'cross-site'
66+
},
67+
{'Sec-Fetch-Site': 'same-origin'},
68+
{'Referer': 'https://map.geo.admin.ch'},
69+
)
70+
def test_feedback_allowed_origin(self, headers):
71+
response = self.app.get(url_for('checker'), headers=headers)
72+
self.assertEqual(response.status_code, 200)
73+
self.assertCors(response, check_origin=False)

0 commit comments

Comments
 (0)