| """ |
| Qwen Coder API - Security Test Script |
| |
| Tests: |
| 1. Protected routes reject requests with no API key (401) |
| 2. Protected routes reject requests with a WRONG key (401) - this also |
| re-confirms the substring-bypass bug is actually fixed (see test 3) |
| 3. A key that's a SUBSTRING of the real one is rejected (401) - this is |
| the specific bug we found and fixed (the old `in` comparison would |
| have let this one through) |
| 4. Protected routes accept requests with the CORRECT key (200) |
| 5. Rate limiting kicks in after RATE_LIMIT requests within 60s (429) |
| 6. Flags routes that are currently unprotected (/add_project, /cancel) |
| so you don't forget they're still open |
| |
| Usage: |
| python test_security.py --url http://localhost:8080 --key YOUR_REAL_API_KEY |
| |
| Or, to read the key from your .env file automatically: |
| pip install python-dotenv |
| python test_security.py --url http://localhost:8080 |
| """ |
|
|
| import argparse |
| import requests |
|
|
| try: |
| from dotenv import load_dotenv |
| import os |
| load_dotenv() |
| except ImportError: |
| pass |
|
|
|
|
| PASS = "\033[92mPASS\033[0m" |
| FAIL = "\033[91mFAIL\033[0m" |
| WARN = "\033[93mWARN\033[0m" |
|
|
|
|
| def check(label: str, condition: bool, detail: str = ""): |
| status = PASS if condition else FAIL |
| print(f" [{status}] {label}" + (f" — {detail}" if detail else "")) |
| return condition |
|
|
|
|
| def test_no_key(base_url: str): |
| print("\n1. Requests with NO API key should be rejected (401)") |
| r = requests.post(f"{base_url}/add_project", json={"project": "test_no_key"}) |
| if r.status_code == 401: |
| check("/add_project rejects missing key", True) |
| else: |
| print(f" [{WARN}] /add_project has no auth protection yet (got {r.status_code}, not 401) — known open item") |
|
|
| r = requests.post(f"{base_url}/chat", json={"project": "default", "message": "hi"}) |
| check("/chat rejects missing key", r.status_code == 401, f"got {r.status_code}") |
|
|
|
|
| def test_wrong_key(base_url: str, real_key: str): |
| print("\n2. Requests with a WRONG key should be rejected (401)") |
| r = requests.post( |
| f"{base_url}/chat", |
| json={"project": "default", "message": "hi"}, |
| headers={"X-API-Key": "definitely-not-the-real-key"}, |
| ) |
| check("/chat rejects wrong key", r.status_code == 401, f"got {r.status_code}") |
|
|
|
|
| def test_substring_bug(base_url: str, real_key: str): |
| print("\n3. A SUBSTRING of the real key should be rejected (the original bug)") |
| if len(real_key) < 4: |
| print(f" [{WARN}] Real key too short to meaningfully test substring bypass, skipping") |
| return |
| substring_key = real_key[: max(3, len(real_key) // 3)] |
| r = requests.post( |
| f"{base_url}/chat", |
| json={"project": "default", "message": "hi"}, |
| headers={"X-API-Key": substring_key}, |
| ) |
| check( |
| "/chat rejects a substring of the real key", |
| r.status_code == 401, |
| f"sent '{substring_key}...', got {r.status_code} (200 here would mean the old bug is back)", |
| ) |
|
|
|
|
| def test_correct_key(base_url: str, real_key: str): |
| print("\n4. Requests WITH the correct key should succeed") |
| r = requests.post(f"{base_url}/add_project", json={"project": "test_correct_key"}) |
| r2 = requests.post( |
| f"{base_url}/chat", |
| json={"project": "test_correct_key", "message": "Say hello in one word."}, |
| headers={"X-API-Key": real_key}, |
| ) |
| check("/chat accepts the correct key", r2.status_code == 200, f"got {r2.status_code}: {r2.text[:200]}") |
|
|
|
|
| def test_rate_limit(base_url: str, real_key: str, rate_limit: int): |
| print(f"\n5. Rate limiting should trigger after {rate_limit} requests/minute") |
| hit_429 = False |
| for i in range(rate_limit + 3): |
| r = requests.post( |
| f"{base_url}/search_web", |
| json={"query": "test"}, |
| headers={"X-API-Key": real_key}, |
| ) |
| if r.status_code == 429: |
| hit_429 = True |
| check("Rate limit triggered", True, f"hit 429 on request #{i + 1}") |
| break |
| if not hit_429: |
| check("Rate limit triggered", False, f"never got a 429 after {rate_limit + 3} requests") |
|
|
|
|
| def test_open_routes(base_url: str): |
| print("\n6. Checking currently-unprotected routes (informational, not pass/fail)") |
| r = requests.post(f"{base_url}/cancel") |
| if r.status_code != 401: |
| print(f" [{WARN}] /cancel has no auth protection (got {r.status_code}) — decide if this needs one") |
|
|
|
|
| if __name__ == "__main__": |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--url", default="http://localhost:8080", help="Base URL of the running app") |
| parser.add_argument("--key", default=None, help="Your real API_KEY (or set it in .env)") |
| parser.add_argument("--rate-limit", type=int, default=10, help="Expected RATE_LIMIT value from app.py") |
| args = parser.parse_args() |
|
|
| real_key = args.key or os.getenv("API_KEY") |
| if not real_key: |
| raise SystemExit("No API key provided. Use --key YOUR_KEY or set API_KEY in a .env file.") |
|
|
| print(f"=== Testing {args.url} ===") |
| test_no_key(args.url) |
| test_wrong_key(args.url, real_key) |
| test_substring_bug(args.url, real_key) |
| test_correct_key(args.url, real_key) |
| test_rate_limit(args.url, real_key, args.rate_limit) |
| test_open_routes(args.url) |
| print("\nDone.") |