Skip to content

Commit e05f45d

Browse files
committed
[tooling] Add authentification tests
1 parent 740fe4f commit e05f45d

6 files changed

Lines changed: 346 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ jobs:
7070
run: make qualify-locally
7171
- name: Run evict tests against local DSS instance
7272
run: make evict-locally
73+
- name: Run security tests against local DSS instance
74+
run: make security-locally
7375
- name: Bring down local DSS instance
7476
run: make down-locally
7577
- name: Collect coverage data
@@ -112,6 +114,8 @@ jobs:
112114
run: make qualify-locally
113115
- name: Run evict tests against local DSS instance
114116
run: make evict-locally
117+
- name: Run security tests against local DSS instance
118+
run: make security-locally
115119
- name: Bring down local DSS instance
116120
run: make down-locally
117121

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,10 @@ probe-locally:
177177
evict-locally:
178178
build/dev/evict_locally.sh
179179

180+
.PHONY: security-locally
181+
security-locally:
182+
build/dev/security_locally.sh
183+
180184
.PHONY: qualify-locally
181185
qualify-locally:
182186
build/dev/qualify_locally.sh

build/dev/security_locally.sh

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env bash
2+
3+
set -eo pipefail
4+
set -x
5+
6+
# Find and change to repo root directory
7+
OS=$(uname)
8+
if [[ "$OS" == "Darwin" ]]; then
9+
# OSX uses BSD readlink
10+
BASEDIR="$(dirname "$0")"
11+
else
12+
BASEDIR=$(readlink -e "$(dirname "$0")")
13+
fi
14+
cd "${BASEDIR}/../.." || exit 1
15+
16+
CORE_SERVICE_CONTAINER="dss_sandbox-local-dss-core-service-1"
17+
OAUTH_CONTAINER="dss_sandbox-local-dss-dummy-oauth-1"
18+
declare -a localhost_containers=("$CORE_SERVICE_CONTAINER" "$OAUTH_CONTAINER")
19+
20+
for container_name in "${localhost_containers[@]}"; do
21+
if [ "$( docker container inspect -f '{{.State.Status}}' "$container_name" )" == "running" ]; then
22+
echo "$container_name available!"
23+
else
24+
echo '#########################################################################'
25+
echo '## Prerequisite to run this command is: ##'
26+
echo '## Local DSS instance + Dummy OAuth server (/build/dev/run_locally.sh) ##'
27+
echo '#########################################################################'
28+
echo "Error: $container_name not running. Execute 'build/dev/run_locally.sh up' before running build/dev/evict_locally.sh";
29+
exit 1;
30+
fi
31+
done
32+
33+
# If yugabyte container is running, assume that we're running in yugabyte mode
34+
if [ "$( docker container inspect -f '{{.State.Status}}' "dss_sandbox-local-dss-ybdb-1" )" == "running" ]; then
35+
echo "Activating yugabyte options"
36+
export DB_HOSTNAME=local-dss-ybdb
37+
export DB_PORT=5433
38+
export DB_USERNAME=yugabyte
39+
else
40+
echo "Staying with cockroachdb defaults options"
41+
fi
42+
43+
if ! python test/security/test.py; then
44+
echo "Evict tests did not succeed."
45+
exit 1
46+
else
47+
echo "Evict tests succeeded."
48+
fi

test/evict/query_helper.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def get_token(self) -> str:
2525
).read()
2626
data = json.loads(r)
2727

28-
if not "access_token":
28+
if "access_token" not in data:
2929
self.logger.error(
3030
"❌ Unable to retrieve access token. Is the dummy auth server running?"
3131
)

test/security/__init__.py

Whitespace-only changes.

test/security/test.py

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
# Small python script to do authentification tests. Use only standard libraries to
2+
# run everywhere.
3+
# Extract URLs directly from the code to be avoid the need of updated API and
4+
# to ensure we get all patterns from the code.
5+
# Assume all endpoint need authentification, but some can be whitelisted there.
6+
# Expect that `start-locally` have been ran
7+
8+
import sys
9+
import logging
10+
import re
11+
import glob
12+
import json
13+
from urllib import request
14+
import http.client
15+
import base64
16+
import hmac
17+
import hashlib
18+
19+
ENDPOINT_WITHOUT_AUTHS = [
20+
"/aux/v1/configuration/accepted_ca_certs",
21+
"/aux/v1/configuration/ca_certs",
22+
"/aux/v1/version",
23+
]
24+
25+
ALL_SCOPES = [
26+
"utm.constraint_management",
27+
"utm.conformance_monitoring_sa",
28+
"utm.strategic_coordination",
29+
"rid.service_provider",
30+
"rid.display_provider",
31+
"dss.write.identification_service_areas",
32+
"dss.read.identification_service_areas",
33+
"interuss.pool_status.read",
34+
"interuss.pool_status.heartbeat.write",
35+
"utm.availability_arbitration",
36+
]
37+
38+
39+
logging.basicConfig(
40+
level=logging.DEBUG,
41+
format="%(asctime)s %(levelname)-8s %(name)-10s %(message)-50s",
42+
handlers=[logging.StreamHandler(sys.stdout)],
43+
)
44+
45+
logger = logging.getLogger("security")
46+
47+
48+
def build_urls():
49+
50+
pattern = re.compile(r'Method:\s*http\.Method(\w+).*?Path:\s*"([^"]+)"')
51+
52+
urls = set()
53+
54+
for filepath in glob.glob("pkg/api/**/*.go", recursive=True):
55+
with open(filepath) as f:
56+
for match in pattern.finditer(f.read()):
57+
method = match.group(1).upper()
58+
path = match.group(2)
59+
urls.add((method, path))
60+
61+
if not urls:
62+
logger.error("❌ No URL found.")
63+
sys.exit(1)
64+
65+
return urls
66+
67+
68+
def get_token(scope=None, audience=None, expire=None):
69+
70+
if scope:
71+
scopes = [scope]
72+
else:
73+
scopes = ALL_SCOPES
74+
75+
if not audience:
76+
audience = "localhost"
77+
78+
r = request.urlopen(
79+
f"http://localhost:8085/token?grant_type=client_credentials&scope={'%20'.join(scopes)}&intended_audience={audience}&issuer=localhost&sub=test_security{'&expire=' + expire if expire else ''}"
80+
).read()
81+
data = json.loads(r)
82+
83+
if "access_token" not in data:
84+
logger.error(
85+
"❌ Unable to retrieve access token. Is the dummy auth server running?"
86+
)
87+
sys.exit(1)
88+
89+
return data["access_token"]
90+
91+
92+
def fill_path(path):
93+
return re.sub(r"\{[^}]+\}", "test", path)
94+
95+
96+
urls = build_urls()
97+
98+
99+
def test_no_authentification(method, path):
100+
101+
logger.debug("❓ Testing without authentification")
102+
103+
conn = http.client.HTTPConnection("localhost:8082")
104+
conn.request(method, fill_path(path))
105+
resp = conn.getresponse()
106+
if resp.status != 401:
107+
logger.error(
108+
f"❌ Unexpected response {resp.status} instead of 401 without authentification header."
109+
)
110+
sys.exit(1)
111+
else:
112+
logger.info("✅ No authentification generated a 401.")
113+
114+
115+
def test_wrong_signature(method, path):
116+
117+
logger.debug("❓ Testing a token with a wrong signature")
118+
119+
invalid_signature = ".".join(get_token().split(".")[:-1] + ["testsig"])
120+
121+
conn = http.client.HTTPConnection("localhost:8082")
122+
conn.request(
123+
method,
124+
fill_path(path),
125+
headers={"Authorization": f"Bearer {invalid_signature}"},
126+
)
127+
resp = conn.getresponse()
128+
if resp.status != 401:
129+
logger.error(
130+
f"❌ Unexpected response {resp.status} instead of 401 with a token with a wrong signature."
131+
)
132+
sys.exit(1)
133+
else:
134+
logger.info("✅ Token with a wrong signature generated a 401.")
135+
136+
137+
def test_wrong_scope(method, path):
138+
139+
logger.debug("❓ Testing a token with a wrong scope")
140+
141+
invalid_scope = get_token("test_wrong_scope")
142+
143+
conn = http.client.HTTPConnection("localhost:8082")
144+
conn.request(
145+
method, fill_path(path), headers={"Authorization": f"Bearer {invalid_scope}"}
146+
)
147+
resp = conn.getresponse()
148+
if resp.status != 403:
149+
logger.error(
150+
f"❌ Unexpected response {resp.status} instead of 403 with a token with a wrong scope."
151+
)
152+
sys.exit(1)
153+
else:
154+
logger.info("✅ Token with a wrong scope generated a 403.")
155+
156+
157+
def test_wrong_audience(method, path):
158+
159+
logger.debug("❓ Testing a token with a wrong audience")
160+
161+
invalid_audience = get_token(audience="test_wrong_audience")
162+
163+
conn = http.client.HTTPConnection("localhost:8082")
164+
conn.request(
165+
method, fill_path(path), headers={"Authorization": f"Bearer {invalid_audience}"}
166+
)
167+
resp = conn.getresponse()
168+
if resp.status != 401:
169+
logger.error(
170+
f"❌ Unexpected response {resp.status} instead of 401 with a token with a wrong audience."
171+
)
172+
sys.exit(1)
173+
else:
174+
logger.info("✅ Token with a wrong audience generated a 401.")
175+
176+
177+
def test_expired(method, path):
178+
179+
logger.debug("❓ Testing an expired token")
180+
181+
invalid_audience = get_token(expire="42") # very old timestamp
182+
183+
conn = http.client.HTTPConnection("localhost:8082")
184+
conn.request(
185+
method, fill_path(path), headers={"Authorization": f"Bearer {invalid_audience}"}
186+
)
187+
resp = conn.getresponse()
188+
if resp.status != 401:
189+
logger.error(
190+
f"❌ Unexpected response {resp.status} instead of 401 with an expired token."
191+
)
192+
sys.exit(1)
193+
else:
194+
logger.info("✅ Expired token generated a 401.")
195+
196+
197+
def test_hs256_token(method, path):
198+
199+
logger.debug("❓ Testing HS256 algorithm confusion attack")
200+
201+
# Read the public key as the HMAC secret
202+
with open("build/test-certs/auth2.pem", "rb") as f:
203+
secret = f.read()
204+
205+
header = (
206+
base64.urlsafe_b64encode(b'{"alg":"HS256","typ":"JWT"}').rstrip(b"=").decode()
207+
)
208+
payload_data = json.dumps(
209+
{"sub": "test", "aud": "localhost", "scope": " ".join(ALL_SCOPES)}
210+
).encode()
211+
payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode()
212+
213+
signing_input = f"{header}.{payload}".encode()
214+
sig = hmac.new(secret, signing_input, hashlib.sha256).digest()
215+
sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b"=").decode()
216+
217+
token = f"{header}.{payload}.{sig_b64}"
218+
219+
conn = http.client.HTTPConnection("localhost:8082")
220+
conn.request(method, fill_path(path), headers={"Authorization": f"Bearer {token}"})
221+
resp = conn.getresponse()
222+
if resp.status != 401:
223+
logger.error(
224+
f"❌ Unexpected response {resp.status} instead of 401 with a HS256 confusion token."
225+
)
226+
sys.exit(1)
227+
else:
228+
logger.info("✅ HS256 confusion attack generated a 401.")
229+
230+
231+
def test_alg_none(method, path):
232+
233+
logger.debug("❓ Testing alg:none attack")
234+
235+
header = (
236+
base64.urlsafe_b64encode(b'{"alg":"none","typ":"JWT"}').rstrip(b"=").decode()
237+
)
238+
payload_data = json.dumps(
239+
{"sub": "test", "aud": "localhost", "scope": " ".join(ALL_SCOPES)}
240+
).encode()
241+
payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode()
242+
243+
token = f"{header}.{payload}."
244+
245+
conn = http.client.HTTPConnection("localhost:8082")
246+
conn.request(method, fill_path(path), headers={"Authorization": f"Bearer {token}"})
247+
resp = conn.getresponse()
248+
if resp.status != 401:
249+
logger.error(
250+
f"❌ Unexpected response {resp.status} instead of 401 with alg:none token."
251+
)
252+
sys.exit(1)
253+
else:
254+
logger.info("✅ alg:none attack generated a 401.")
255+
256+
257+
def test_valid_token(method, path):
258+
259+
logger.debug("❓ Testing a valid token")
260+
261+
valid_token = get_token()
262+
263+
conn = http.client.HTTPConnection("localhost:8082")
264+
conn.request(
265+
method, fill_path(path), headers={"Authorization": f"Bearer {valid_token}"}
266+
)
267+
resp = conn.getresponse()
268+
if resp.status in [401, 403]:
269+
logger.error(f"❌ Unexpected response {resp.status} since the token in valid.")
270+
sys.exit(1)
271+
else:
272+
logger.info(f"✅ Token with a wrong scope generated a {resp.status}.")
273+
274+
275+
for method, path in sorted(urls):
276+
logger.info(f"📋 Testing {method} {path}")
277+
278+
if path in ENDPOINT_WITHOUT_AUTHS:
279+
logger.info("✅ Endpoint is not protected.")
280+
continue
281+
282+
test_no_authentification(method, path)
283+
test_wrong_signature(method, path)
284+
test_wrong_scope(method, path)
285+
test_wrong_audience(method, path)
286+
test_expired(method, path)
287+
test_hs256_token(method, path)
288+
test_alg_none(method, path)
289+
test_valid_token(method, path)

0 commit comments

Comments
 (0)