8000 security(web): header-only API key; reject option-like package names with 400 by devin-ai-integration[bot] · Pull Request #108 · dominicusin/package-maximizer · GitHub
[go: up one dir, main page]

Skip to content
33 changes: 25 additions & 8 deletions package_maximizer/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import hmac
import os
import re
import time
from functools import wraps
from typing import Any
Expand Down Expand Up @@ -61,6 +62,19 @@ def reset(self, key: str) -> None:
# ─── Input validation ────────────────────────────────────────
MAX_PACKAGES = int(os.environ.get("PM_MAX_PACKAGES", "5000"))
MAX_NAME_LEN = int(os.environ.get("PM_MAX_NAME_LEN", "256"))
_NAME_RE = re.compile(r"^[^\s-]\S*$")


def _check_name(name: str) -> str:
"""Return a stripped package name or raise ``ValueError`` if it is unsafe."""
name = name.strip()
if not name:
raise ValueError("Package names must not be empty")
if len(name) > MAX_NAME_LEN:
raise ValueError(f"Package name too long (max {MAX_NAME_LEN} chars)")
if not _NAME_RE.match(name):
raise ValueError(f"Invalid package name {name!r}")
return name


def validate_maximize_payload(data: Any) -> tuple[list[str], list, dict | None]:
Expand All @@ -83,12 +97,7 @@ def validate_maximize_payload(data: Any) -> tuple[list[str], list, dict | None]:
for pkg in raw_packages:
if not isinstance(pkg, str):
raise ValueError("Each package name must be a string")
name = pkg.strip()
if not name:
raise ValueError("Package names must not be empty")
if len(name) > MAX_NAME_LEN:
raise ValueError(f"Package name too long (max {MAX_NAME_LEN} chars)")
cleaned.append(name)
cleaned.append(_check_name(pkg))

if len(cleaned) > MAX_PACKAGES:
raise ValueError(f"Too many packages (max {MAX_PACKAGES})")
Expand Down Expand Up @@ -136,8 +145,9 @@ def decorated(*args, **kwargs):
if request.endpoint == "health":
return f(*args, **kwargs)

# Check API key
api_key = request.headers.get("X-API-Key") or request.args.get("api_key")
# Check API key (header-only: query-string keys are rejected so the
# secret never lands in access logs or browser history).
api_key = request.headers.get("X-API-Key")
if not api_key or not hmac.compare_digest(api_key, API_KEY):
return (
jsonify(
Expand Down Expand Up @@ -462,6 +472,13 @@ def propose_post() -> tuple[dict, int]:
400,
)

try:
for pkg_name in packages:
if isinstance(pkg_name, str):
_check_name(pkg_name)
except ValueError as e:
return jsonify({"error": "Bad Request", "message": str(e)}), 400

manager = data.get("manager", "apt")
solver = data.get("solver", "greedy")
explain = data.get("explain", False)
Expand Down
23 changes: 23 additions & 0 deletions tests/test_web_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,3 +408,26 @@ def test_export_post_returns_selected(client):
def test_compare_missing_packages(client):
r = client.post("/api/v1/compare", json={}, headers=auth_headers())
assert r.status_code == 400


# --- Hardening -----------------------------------------------------------------


def test_api_key_in_query_string_is_rejected(client):
r = client.post(f"/api/v1/maximize?api_key={DEFAULT_KEY}", json={"packages": ["a"]})
assert r.status_code == 401


@pytest.mark.parametrize("name", ["--registry=http://evil", "-h", "foo bar"])
def test_maximize_rejects_option_like_names(client, name):
r = client.post(
"/api/v1/maximize", json={"packages": [name]}, headers=auth_headers()
)
assert r.status_code == 400
assert "Invalid package name" in r.get_json()["message"]


def test_propose_rejects_option_like_names(client):
payload = {"packages": ["--registry=http://evil"], "manager": "apt"}
r = client.post("/api/v1/propose", json=payload, headers=auth_headers())
assert r.status_code == 400
Loading
0