Skip to content

Commit 0f7f3eb

Browse files
committed
fix(devserver): tighten cors origin validation
Use a full regex match for preview Braintrust origins so crafted hostnames that merely share the allowed prefix are rejected. Add focused tests for valid preview origins, suffix bypass attempts, and OPTIONS header reflection behavior.
1 parent 0daac2f commit 0f7f3eb

2 files changed

Lines changed: 46 additions & 1 deletion

File tree

py/src/braintrust/devserver/cors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def check_origin(origin: str) -> bool:
5858
for allowed in ALLOWED_ORIGINS:
5959
if isinstance(allowed, str) and origin == allowed:
6060
return True
61-
elif isinstance(allowed, re.Pattern) and allowed.match(origin):
61+
elif isinstance(allowed, re.Pattern) and allowed.fullmatch(origin):
6262
return True
6363

6464
return False
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import asyncio
2+
import unittest
3+
4+
from braintrust.devserver.cors import check_origin, create_cors_middleware
5+
6+
7+
class TestCorsOriginValidation(unittest.TestCase):
8+
def test_check_origin_allows_legitimate_preview_origin(self):
9+
self.assertTrue(check_origin("https://legit.preview.braintrust.dev"))
10+
11+
def test_check_origin_rejects_preview_suffix_bypass(self):
12+
self.assertFalse(check_origin("https://evil.preview.braintrust.dev.attacker.com"))
13+
14+
def test_options_response_does_not_reflect_disallowed_origin(self):
15+
async def app(scope, receive, send):
16+
raise AssertionError("OPTIONS requests should be handled by the CORS middleware")
17+
18+
middleware = create_cors_middleware()(app)
19+
messages = []
20+
21+
async def receive():
22+
return {"type": "http.request", "body": b"", "more_body": False}
23+
24+
async def send(message):
25+
messages.append(message)
26+
27+
scope = {
28+
"type": "http",
29+
"method": "OPTIONS",
30+
"headers": [
31+
(b"origin", b"https://evil.preview.braintrust.dev.attacker.com"),
32+
(b"access-control-request-method", b"POST"),
33+
],
34+
}
35+
36+
asyncio.run(middleware(scope, receive, send))
37+
38+
response_start = next(message for message in messages if message["type"] == "http.response.start")
39+
headers = dict(response_start["headers"])
40+
self.assertNotIn(b"access-control-allow-origin", headers)
41+
self.assertNotIn(b"access-control-allow-credentials", headers)
42+
43+
44+
if __name__ == "__main__":
45+
unittest.main()

0 commit comments

Comments
 (0)