devlogs 2.3.2__tar.gz → 2.3.5__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 (82) hide show
  1. {devlogs-2.3.2/src/devlogs.egg-info → devlogs-2.3.5}/PKG-INFO +1 -1
  2. {devlogs-2.3.2 → devlogs-2.3.5}/pyproject.toml +1 -1
  3. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/_version_static.py +1 -1
  4. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/cli.py +14 -0
  5. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/collector/server.py +24 -21
  6. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/config.py +18 -2
  7. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/web/server.py +4 -2
  8. {devlogs-2.3.2 → devlogs-2.3.5/src/devlogs.egg-info}/PKG-INFO +1 -1
  9. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_cli.py +22 -0
  10. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_collector_server.py +58 -5
  11. {devlogs-2.3.2 → devlogs-2.3.5}/LICENSE +0 -0
  12. {devlogs-2.3.2 → devlogs-2.3.5}/MANIFEST.in +0 -0
  13. {devlogs-2.3.2 → devlogs-2.3.5}/README.md +0 -0
  14. {devlogs-2.3.2 → devlogs-2.3.5}/setup.cfg +0 -0
  15. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/__init__.py +0 -0
  16. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/__main__.py +0 -0
  17. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/build_info.py +0 -0
  18. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/collector/__init__.py +0 -0
  19. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/collector/auth.py +0 -0
  20. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/collector/cli.py +0 -0
  21. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/collector/errors.py +0 -0
  22. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/collector/forwarder.py +0 -0
  23. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/collector/ingestor.py +0 -0
  24. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/collector/loki_plugin.py +0 -0
  25. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/collector/plugins.py +0 -0
  26. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/collector/schema.py +0 -0
  27. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/context.py +0 -0
  28. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/demo.py +0 -0
  29. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/devlogs_client.py +0 -0
  30. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/formatting.py +0 -0
  31. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/handler.py +0 -0
  32. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/jenkins/__init__.py +0 -0
  33. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/jenkins/cli.py +0 -0
  34. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/jenkins/core.py +0 -0
  35. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/levels.py +0 -0
  36. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/loki/__init__.py +0 -0
  37. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/loki/queries.py +0 -0
  38. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/mcp/__init__.py +0 -0
  39. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/mcp/server.py +0 -0
  40. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/opensearch/__init__.py +0 -0
  41. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/opensearch/client.py +0 -0
  42. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/opensearch/indexing.py +0 -0
  43. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/opensearch/mappings.py +0 -0
  44. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/opensearch/queries.py +0 -0
  45. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/proxy/__init__.py +0 -0
  46. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/proxy/server.py +0 -0
  47. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/retention.py +0 -0
  48. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/scrub.py +0 -0
  49. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/time_utils.py +0 -0
  50. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/version.py +0 -0
  51. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/web/__init__.py +0 -0
  52. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/web/static/devlogs.css +0 -0
  53. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/web/static/devlogs.js +0 -0
  54. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/web/static/index.html +0 -0
  55. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs/wrapper.py +0 -0
  56. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs.egg-info/SOURCES.txt +0 -0
  57. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs.egg-info/dependency_links.txt +0 -0
  58. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs.egg-info/entry_points.txt +0 -0
  59. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs.egg-info/requires.txt +0 -0
  60. {devlogs-2.3.2 → devlogs-2.3.5}/src/devlogs.egg-info/top_level.txt +0 -0
  61. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_build_info.py +0 -0
  62. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_collector_auth.py +0 -0
  63. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_collector_config.py +0 -0
  64. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_collector_plugins.py +0 -0
  65. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_collector_schema.py +0 -0
  66. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_config.py +0 -0
  67. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_context.py +0 -0
  68. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_devlogs_client.py +0 -0
  69. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_formatting.py +0 -0
  70. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_handler.py +0 -0
  71. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_indexing.py +0 -0
  72. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_levels.py +0 -0
  73. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_mappings.py +0 -0
  74. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_mcp_server.py +0 -0
  75. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_opensearch_client.py +0 -0
  76. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_opensearch_queries.py +0 -0
  77. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_proxy_server.py +0 -0
  78. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_retention.py +0 -0
  79. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_scrub.py +0 -0
  80. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_time_utils.py +0 -0
  81. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_url_parsing.py +0 -0
  82. {devlogs-2.3.2 → devlogs-2.3.5}/tests/test_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlogs
3
- Version: 2.3.2
3
+ Version: 2.3.5
4
4
  Summary: Developer-focused logging library for Python with OpenSearch integration.
5
5
  Author-email: Dan Driscoll <dan@thedandriscoll.org>
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devlogs"
7
- version = "2.3.2"
7
+ version = "2.3.5"
8
8
  description = "Developer-focused logging library for Python with OpenSearch integration."
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -1,2 +1,2 @@
1
1
  # AUTO-GENERATED at build time — do not edit or commit
2
- __version__ = "2.3.2"
2
+ __version__ = "2.3.5"
@@ -926,6 +926,13 @@ def tail(
926
926
  _apply_common_options(env, url)
927
927
 
928
928
  cfg = load_config()
929
+ if cfg.url_mode == "collector":
930
+ typer.echo(typer.style(
931
+ "Error: the provided URL is a collector (ingest) endpoint and cannot be used for querying.\n"
932
+ "For Loki backends, use lokis://TOKEN@host/path instead of https://TOKEN@host/path",
933
+ fg=typer.colors.RED
934
+ ), err=True)
935
+ raise typer.Exit(1)
929
936
  if cfg.is_loki:
930
937
  _tail_loki(cfg, application=application, operation_id=operation_id, area=area,
931
938
  component=component, level=level, since=since, limit=limit, follow=follow,
@@ -1115,6 +1122,13 @@ def search(
1115
1122
  _apply_common_options(env, url)
1116
1123
 
1117
1124
  cfg = load_config()
1125
+ if cfg.url_mode == "collector":
1126
+ typer.echo(typer.style(
1127
+ "Error: the provided URL is a collector (ingest) endpoint and cannot be used for querying.\n"
1128
+ "For Loki backends, use lokis://TOKEN@host/path instead of https://TOKEN@host/path",
1129
+ fg=typer.colors.RED
1130
+ ), err=True)
1131
+ raise typer.Exit(1)
1118
1132
  if cfg.is_loki:
1119
1133
  _search_loki(cfg, q=q, application=application, area=area,
1120
1134
  component=component, level=level, operation_id=operation_id,
@@ -4,6 +4,7 @@
4
4
  # - Forward mode: proxy to upstream collector
5
5
  # - Ingest mode: write directly to OpenSearch
6
6
 
7
+ import hmac
7
8
  import json
8
9
  import logging
9
10
  import platform
@@ -127,27 +128,28 @@ app.add_middleware(
127
128
  )
128
129
 
129
130
 
130
- def get_client_ip(request: Request) -> str:
131
+ def get_client_ip(request: Request, trusted_proxy_token: str = "") -> str:
131
132
  """Extract client IP from request.
132
133
 
133
134
  Returns a bare IPv4 or IPv6 address string (e.g. "192.168.1.5", "::1").
134
135
  No port, no brackets, no CIDR suffix.
135
136
 
136
- Checks X-Forwarded-For header first (for proxied requests),
137
- then falls back to direct client connection.
137
+ Proxy headers (X-Forwarded-For, X-Real-IP) are only honored when
138
+ DEVLOGS_TRUSTED_PROXY_TOKEN is configured and the request presents
139
+ a matching X-Trusted-Proxy-Token header. This prevents clients from
140
+ spoofing their IP address.
138
141
  """
139
- # Check X-Forwarded-For header (from reverse proxy)
140
- forwarded_for = request.headers.get("X-Forwarded-For")
141
- if forwarded_for:
142
- # Take the first (leftmost) IP in the chain
143
- return forwarded_for.split(",")[0].strip()
144
-
145
- # Check X-Real-IP header (alternative proxy header)
146
- real_ip = request.headers.get("X-Real-IP")
147
- if real_ip:
148
- return real_ip.strip()
149
-
150
- # Fall back to direct client
142
+ if trusted_proxy_token:
143
+ presented = request.headers.get("X-Trusted-Proxy-Token", "")
144
+ if presented and hmac.compare_digest(presented, trusted_proxy_token):
145
+ forwarded_for = request.headers.get("X-Forwarded-For")
146
+ if forwarded_for:
147
+ return forwarded_for.split(",")[0].strip()
148
+
149
+ real_ip = request.headers.get("X-Real-IP")
150
+ if real_ip:
151
+ return real_ip.strip()
152
+
151
153
  if request.client:
152
154
  return request.client.host
153
155
 
@@ -157,7 +159,8 @@ def get_client_ip(request: Request) -> str:
157
159
  @app.exception_handler(CollectorError)
158
160
  async def collector_error_handler(request: Request, exc: CollectorError):
159
161
  """Handle CollectorError exceptions with structured response."""
160
- client_ip = get_client_ip(request)
162
+ cfg = load_config()
163
+ client_ip = get_client_ip(request, cfg.trusted_proxy_token)
161
164
  logger.warning("%s %d %s: %s", client_ip, exc.status_code, exc.subcode, exc.message)
162
165
  return JSONResponse(
163
166
  status_code=exc.status_code,
@@ -202,7 +205,7 @@ async def ingest_logs(request: Request):
202
205
 
203
206
  # Determine operating mode
204
207
  mode = cfg.get_collector_mode()
205
- client_ip = get_client_ip(request)
208
+ client_ip = get_client_ip(request, cfg.trusted_proxy_token)
206
209
  logger.info("%s POST / (%d bytes)", client_ip, len(body))
207
210
 
208
211
  if mode == "forward":
@@ -240,7 +243,7 @@ async def _handle_forward_mode(request: Request, cfg, body: bytes) -> Response:
240
243
  timeout=cfg.opensearch_timeout,
241
244
  )
242
245
 
243
- client_ip = get_client_ip(request)
246
+ client_ip = get_client_ip(request, cfg.trusted_proxy_token)
244
247
 
245
248
  # If upstream returned 2xx, return 202
246
249
  if 200 <= status < 300:
@@ -297,7 +300,7 @@ def _validate_and_enrich_records(request: Request, cfg, body: bytes):
297
300
  raise
298
301
 
299
302
  # Get client info
300
- client_ip = get_client_ip(request)
303
+ client_ip = get_client_ip(request, cfg.trusted_proxy_token)
301
304
 
302
305
  # Extract token (precedence: Bearer header → X-Devlogs-Token → URL userinfo → ?token=)
303
306
  authorization = request.headers.get("Authorization")
@@ -342,7 +345,7 @@ async def _handle_ingest_mode(request: Request, cfg, body: bytes) -> Response:
342
345
  index_map = parse_forward_index_map_kv(cfg.forward_index_map_kv)
343
346
 
344
347
  result = ingest_records(client, cfg.index, enriched_records, index_map)
345
- client_ip = get_client_ip(request)
348
+ client_ip = get_client_ip(request, cfg.trusted_proxy_token)
346
349
  logger.info("%s 202 ingested %d record(s)", client_ip, result["ingested"])
347
350
 
348
351
  return Response(
@@ -377,7 +380,7 @@ async def _handle_plugin_mode(request: Request, cfg, body: bytes, plugin) -> Res
377
380
  result = {}
378
381
 
379
382
  ingested = result.get("ingested", len(enriched_records))
380
- client_ip = get_client_ip(request)
383
+ client_ip = get_client_ip(request, cfg.trusted_proxy_token)
381
384
  logger.info("%s 202 plugin '%s' ingested %d record(s)", client_ip, plugin.name, ingested)
382
385
 
383
386
  return Response(
@@ -49,6 +49,9 @@ _DEVLOGS_CONFIG_KEYS = (
49
49
  "DEVLOGS_TOKEN_MAP_KV",
50
50
  # Legacy: Auth token header name (default: Authorization)
51
51
  "DEVLOGS_AUTH_HEADER",
52
+ # Proxy trust: shared secret that a reverse proxy must present to have
53
+ # X-Forwarded-For / X-Real-IP headers honored
54
+ "DEVLOGS_TRUSTED_PROXY_TOKEN",
52
55
  )
53
56
 
54
57
 
@@ -411,8 +414,9 @@ class DevlogsConfig:
411
414
  # Treat unparseable DEVLOGS_URL as legacy OpenSearch
412
415
  opensearch_url_config = _parse_opensearch_url(devlogs_url)
413
416
 
414
- # Legacy: DEVLOGS_OPENSEARCH_URL overrides OpenSearch settings from DEVLOGS_URL
415
- if legacy_opensearch_url:
417
+ # Legacy: DEVLOGS_OPENSEARCH_URL overrides OpenSearch settings from DEVLOGS_URL,
418
+ # but NOT when the URL was explicitly set via --url flag.
419
+ if legacy_opensearch_url and not _url_set_explicitly:
416
420
  opensearch_url_config = _parse_opensearch_url(legacy_opensearch_url)
417
421
  # Parse application filter from opensearch:// URL (second path segment)
418
422
  if legacy_opensearch_url.startswith("opensearchs://") or legacy_opensearch_url.startswith("opensearch://"):
@@ -465,6 +469,11 @@ class DevlogsConfig:
465
469
  # Token-to-identity mapping (KV format)
466
470
  self.token_map_kv = _getenv("DEVLOGS_TOKEN_MAP_KV", "")
467
471
 
472
+ # Trusted proxy token: if set, X-Forwarded-For / X-Real-IP headers are
473
+ # only honored when the request includes this token in X-Trusted-Proxy-Token.
474
+ # If empty, forwarded headers are never trusted.
475
+ self.trusted_proxy_token = _getenv("DEVLOGS_TRUSTED_PROXY_TOKEN", "")
476
+
468
477
  # Forward mode: per-application index routing (KV format)
469
478
  self.forward_index_map_kv = _getenv("DEVLOGS_FORWARD_INDEX_MAP_KV", "")
470
479
 
@@ -531,15 +540,22 @@ def set_dotenv_path(path: str):
531
540
  _dotenv_loaded = False # Reset to force reload with new path
532
541
 
533
542
 
543
+ _url_set_explicitly = False
544
+
534
545
  def set_url(url: str):
535
546
  """Set the URL, auto-detecting whether it's a collector or OpenSearch URL.
536
547
 
537
548
  Uses parse_url() to detect the URL type:
538
549
  - Collector URLs → sets DEVLOGS_URL
539
550
  - OpenSearch URLs → sets DEVLOGS_OPENSEARCH_URL
551
+
552
+ When called (i.e. via --url flag), marks the URL as explicitly set so that
553
+ dotenv values cannot silently override it.
540
554
  """
555
+ global _url_set_explicitly
541
556
  if not url:
542
557
  return
558
+ _url_set_explicitly = True
543
559
  try:
544
560
  parsed = parse_url(url)
545
561
  except URLParseError:
@@ -78,8 +78,10 @@ def tail(operation_id: Optional[str] = None, area: Optional[str] = None, compone
78
78
 
79
79
  @app.get("/ui/{path:path}")
80
80
  def serve_ui(path: str):
81
- static_dir = os.path.join(os.path.dirname(__file__), "static")
82
- file_path = os.path.join(static_dir, path)
81
+ static_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), "static"))
82
+ file_path = os.path.realpath(os.path.join(static_dir, path))
83
+ if not file_path.startswith(static_dir + os.sep) and file_path != static_dir:
84
+ file_path = os.path.join(static_dir, "index.html")
83
85
  if not os.path.isfile(file_path):
84
86
  file_path = os.path.join(static_dir, "index.html")
85
87
  return FileResponse(file_path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlogs
3
- Version: 2.3.2
3
+ Version: 2.3.5
4
4
  Summary: Developer-focused logging library for Python with OpenSearch integration.
5
5
  Author-email: Dan Driscoll <dan@thedandriscoll.org>
6
6
  License: MIT License
@@ -919,3 +919,25 @@ class TestLokiCLI:
919
919
  result = runner.invoke(cli.app, ["--url", "lokis://token@host.example.io/query", "last-error"])
920
920
  assert result.exit_code == 1
921
921
  assert "--application is required" in result.output
922
+
923
+ def test_tail_rejects_collector_url(self, monkeypatch):
924
+ """Test that tail fails with a clear error for collector (https) URLs."""
925
+ monkeypatch.setattr(config, "_dotenv_loaded", True)
926
+ monkeypatch.delenv("DEVLOGS_OPENSEARCH_URL", raising=False)
927
+ monkeypatch.delenv("DEVLOGS_OPENSEARCH_HOST", raising=False)
928
+ runner = CliRunner()
929
+ result = runner.invoke(cli.app, ["--url", "https://token@host.example.io/ingest", "tail"])
930
+ assert result.exit_code == 1
931
+ assert "collector" in result.output
932
+ assert "lokis://" in result.output
933
+
934
+ def test_search_rejects_collector_url(self, monkeypatch):
935
+ """Test that search fails with a clear error for collector (https) URLs."""
936
+ monkeypatch.setattr(config, "_dotenv_loaded", True)
937
+ monkeypatch.delenv("DEVLOGS_OPENSEARCH_URL", raising=False)
938
+ monkeypatch.delenv("DEVLOGS_OPENSEARCH_HOST", raising=False)
939
+ runner = CliRunner()
940
+ result = runner.invoke(cli.app, ["--url", "https://token@host.example.io/ingest", "search"])
941
+ assert result.exit_code == 1
942
+ assert "collector" in result.output
943
+ assert "lokis://" in result.output
@@ -166,7 +166,7 @@ class TestIngestEndpoint:
166
166
  # TestClient uses testclient as host
167
167
  assert body["client_ip"] is not None
168
168
 
169
- def test_captures_forwarded_ip(self, client, reset_config, monkeypatch):
169
+ def test_ignores_forwarded_ip_without_proxy_token(self, client, reset_config, monkeypatch):
170
170
  monkeypatch.setenv("DEVLOGS_OPENSEARCH_HOST", "localhost")
171
171
  monkeypatch.setenv("DEVLOGS_INDEX", "test-index")
172
172
 
@@ -184,6 +184,32 @@ class TestIngestEndpoint:
184
184
  headers={"X-Forwarded-For": "192.168.1.100, 10.0.0.1"}
185
185
  )
186
186
 
187
+ assert response.status_code == 202
188
+ body = mock_client.index.call_args.kwargs["body"]
189
+ assert body["client_ip"] != "192.168.1.100"
190
+
191
+ def test_captures_forwarded_ip_with_proxy_token(self, client, reset_config, monkeypatch):
192
+ monkeypatch.setenv("DEVLOGS_OPENSEARCH_HOST", "localhost")
193
+ monkeypatch.setenv("DEVLOGS_INDEX", "test-index")
194
+ monkeypatch.setenv("DEVLOGS_TRUSTED_PROXY_TOKEN", "my-proxy-secret")
195
+
196
+ mock_client = Mock()
197
+ mock_client.index = Mock(return_value={})
198
+
199
+ with patch("devlogs.collector.server.get_opensearch_client", return_value=mock_client):
200
+ response = client.post(
201
+ "/",
202
+ json={
203
+ "application": "test-app",
204
+ "component": "api",
205
+ "timestamp": "2024-01-15T10:30:00Z"
206
+ },
207
+ headers={
208
+ "X-Forwarded-For": "192.168.1.100, 10.0.0.1",
209
+ "X-Trusted-Proxy-Token": "my-proxy-secret",
210
+ }
211
+ )
212
+
187
213
  assert response.status_code == 202
188
214
  body = mock_client.index.call_args.kwargs["body"]
189
215
  assert body["client_ip"] == "192.168.1.100"
@@ -575,17 +601,44 @@ class TestForwardMode:
575
601
  class TestGetClientIp:
576
602
  """Tests for client IP extraction."""
577
603
 
578
- def test_extracts_forwarded_for(self):
604
+ def test_ignores_forwarded_for_without_token(self):
579
605
  mock_request = Mock()
580
606
  mock_request.headers = {"X-Forwarded-For": "192.168.1.1, 10.0.0.1"}
581
607
  mock_request.client = Mock(host="127.0.0.1")
582
- assert get_client_ip(mock_request) == "192.168.1.1"
608
+ assert get_client_ip(mock_request) == "127.0.0.1"
583
609
 
584
- def test_extracts_real_ip(self):
610
+ def test_ignores_real_ip_without_token(self):
585
611
  mock_request = Mock()
586
612
  mock_request.headers = {"X-Real-IP": "192.168.1.1"}
587
613
  mock_request.client = Mock(host="127.0.0.1")
588
- assert get_client_ip(mock_request) == "192.168.1.1"
614
+ assert get_client_ip(mock_request) == "127.0.0.1"
615
+
616
+ def test_extracts_forwarded_for_with_proxy_token(self):
617
+ mock_request = Mock()
618
+ mock_request.headers = {
619
+ "X-Forwarded-For": "192.168.1.1, 10.0.0.1",
620
+ "X-Trusted-Proxy-Token": "secret",
621
+ }
622
+ mock_request.client = Mock(host="127.0.0.1")
623
+ assert get_client_ip(mock_request, "secret") == "192.168.1.1"
624
+
625
+ def test_extracts_real_ip_with_proxy_token(self):
626
+ mock_request = Mock()
627
+ mock_request.headers = {
628
+ "X-Real-IP": "192.168.1.1",
629
+ "X-Trusted-Proxy-Token": "secret",
630
+ }
631
+ mock_request.client = Mock(host="127.0.0.1")
632
+ assert get_client_ip(mock_request, "secret") == "192.168.1.1"
633
+
634
+ def test_ignores_forwarded_for_with_wrong_token(self):
635
+ mock_request = Mock()
636
+ mock_request.headers = {
637
+ "X-Forwarded-For": "192.168.1.1, 10.0.0.1",
638
+ "X-Trusted-Proxy-Token": "wrong",
639
+ }
640
+ mock_request.client = Mock(host="127.0.0.1")
641
+ assert get_client_ip(mock_request, "secret") == "127.0.0.1"
589
642
 
590
643
  def test_falls_back_to_client(self):
591
644
  mock_request = Mock()
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