Skip to content

Commit 15aed2e

Browse files
committed
security: constrain demo API origin
1 parent 754e936 commit 15aed2e

2 files changed

Lines changed: 78 additions & 4 deletions

File tree

‎examples/demo.py‎

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,53 @@
44
from __future__ import annotations
55

66
import argparse
7+
import ipaddress
78
import json
89
import os
910
import sys
1011
import urllib.error
12+
import urllib.parse
1113
import urllib.request
1214
from typing import Any
1315

1416

17+
def validate_api_base(value: str) -> str:
18+
"""Return a canonical HTTP origin for an explicit numeric-loopback listener."""
19+
20+
message = "--api-base must be an HTTP numeric-loopback root origin with an explicit port"
21+
try:
22+
parsed = urllib.parse.urlsplit(value)
23+
port = parsed.port
24+
address = ipaddress.ip_address(parsed.hostname or "")
25+
except (TypeError, ValueError) as exc:
26+
raise ValueError(message) from exc
27+
28+
if (
29+
parsed.scheme != "http"
30+
or not address.is_loopback
31+
or getattr(address, "ipv4_mapped", None) is not None
32+
or getattr(address, "scope_id", None) is not None
33+
or parsed.username is not None
34+
or parsed.password is not None
35+
or port is None
36+
or parsed.path not in {"", "/"}
37+
or parsed.query
38+
or parsed.fragment
39+
):
40+
raise ValueError(message)
41+
42+
host = f"[{address.compressed}]" if address.version == 6 else address.compressed
43+
return f"http://{host}:{port}"
44+
45+
1546
def request_json(
1647
api_base: str,
1748
path: str,
1849
*,
1950
body: dict[str, object] | None = None,
2051
token: str | None = None,
2152
) -> dict[str, Any]:
53+
api_origin = validate_api_base(api_base)
2254
headers = {"Accept": "application/json"}
2355
data = None
2456
method = "GET"
@@ -29,20 +61,21 @@ def request_json(
2961
if token:
3062
headers["Authorization"] = f"Bearer {token}"
3163

32-
request = urllib.request.Request(
33-
api_base.rstrip("/") + path,
64+
# The URL origin is canonicalized above without DNS resolution or userinfo.
65+
request = urllib.request.Request( # noqa: S310
66+
api_origin + path,
3467
data=data,
3568
headers=headers,
3669
method=method,
3770
)
3871
try:
39-
with urllib.request.urlopen(request, timeout=30) as response:
72+
with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310
4073
return json.load(response)
4174
except urllib.error.HTTPError as exc:
4275
detail = exc.read().decode("utf-8", errors="replace")
4376
raise RuntimeError(f"{path} returned HTTP {exc.code}: {detail}") from exc
4477
except urllib.error.URLError as exc:
45-
raise RuntimeError(f"could not reach {api_base}: {exc.reason}") from exc
78+
raise RuntimeError(f"could not reach {api_origin}: {exc.reason}") from exc
4679

4780

4881
def print_result(label: str, payload: dict[str, Any]) -> None:

‎tests/test_demo.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
from examples.demo import validate_api_base
6+
7+
8+
@pytest.mark.parametrize(
9+
("value", "expected"),
10+
[
11+
("http://127.0.0.1:8092", "http://127.0.0.1:8092"),
12+
("http://127.0.0.2:80/", "http://127.0.0.2:80"),
13+
("http://[::1]:8092", "http://[::1]:8092"),
14+
],
15+
)
16+
def test_validate_api_base_accepts_only_canonicalizable_loopback_origins(
17+
value: str,
18+
expected: str,
19+
) -> None:
20+
assert validate_api_base(value) == expected
21+
22+
23+
@pytest.mark.parametrize(
24+
"value",
25+
[
26+
"https://127.0.0.1:8092",
27+
"http://localhost:8092",
28+
"http://192.0.2.1:8092",
29+
"http://127.0.0.1",
30+
"http://127.0.0.1:8092/path",
31+
"http://user@127.0.0.1:8092",
32+
"http://127.0.0.1:8092?next=https://example.com",
33+
"http://127.0.0.1:8092#fragment",
34+
"http://[::ffff:127.0.0.1]:8092",
35+
"http://[::1%25loopback]:8092",
36+
"not-a-url",
37+
],
38+
)
39+
def test_validate_api_base_rejects_non_loopback_or_non_origin_values(value: str) -> None:
40+
with pytest.raises(ValueError, match="numeric-loopback root origin"):
41+
validate_api_base(value)

0 commit comments

Comments
 (0)