v0.50.257: 4 Opus pre-release follow-ups + CHANGELOG + test fixes for #1415
stage-257 batch (PRs #1402 + #1415): Opus pre-release advisor caught 4 issues in stage-257: 1. MUST-FIX (security): api/oauth.py::_write_auth_json — tmp.replace() preserves the temp file umask (0644 default), so OAuth access/refresh tokens landed world-readable on shared systems. Fix: tmp.chmod(0o600) BEFORE rename, with try/except OSError that warns but does not abort. 2. SHOULD-FIX: _handle_cron_history and _handle_cron_run_detail accepted job_id as a path component without validation. Mirrors the rollback path-traversal vector caught in v0.50.255 (#1405). Path() / .. does NOT normalize. New regex ^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$ with explicit . / .. rejection. 3. SHOULD-FIX: _handle_cron_history int(offset)/int(limit) raised ValueError on malformed input → confusing 500. Now try/except + clamp to (max(0, offset), max(1, min(500, limit))). 4. NIT: same regex applied to _handle_cron_run_detail (defense-in-depth even though path-resolve check would catch it downstream). PR #1415 follow-up: 8 pre-existing tests in test_issue1106 and test_custom_provider_display_name asserted bare model IDs but #1415 changes named-custom-provider IDs to @custom:NAME:model form when active provider differs. Tests updated to use _strip_at_prefix helper to keep checking the same invariant in the new shape. 4 regression tests in test_v050257_opus_followups.py + 8 fixed pre-existing tests. Full suite: 3602 passed, 0 failed.
This commit is contained in:
20
CHANGELOG.md
20
CHANGELOG.md
@@ -2,6 +2,26 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.50.257] — 2026-05-01
|
||||
|
||||
### Added
|
||||
- **Cron run history + full-output viewer** (#468) — new `GET /api/crons/history?job_id=X&offset=N&limit=M` endpoint lists all output files for a job (filename + size + mtime) without loading content. New `GET /api/crons/run?job_id=X&filename=Y` returns full content + a snippet extracted from the `## Response` section. Tasks panel renders a per-job run history with click-to-expand. (`api/routes.py`, `static/panels.js`, `static/i18n.js`) @bergeouss — PR #1402, fixes #468
|
||||
|
||||
- **Per-session toolset overrides** (#493) — new `Session.enabled_toolsets: list[str] | None` field threaded through `_run_agent_streaming`. New `POST /api/session/toolsets` endpoint validates input shape (non-empty list of non-empty strings, or null to clear). Settings panel adds a per-session toolset chip with global/custom modes. Honors the override at the streaming hot path via `_resolve_cli_toolsets`. (`api/models.py`, `api/routes.py`, `api/streaming.py`, `static/panels.js`, `static/i18n.js`, `static/index.html`, `static/style.css`, `static/ui.js`) @bergeouss — PR #1402, fixes #493
|
||||
|
||||
- **Codex OAuth in-app device-code flow** — new `api/oauth.py` (stdlib only — no external HTTP libs). Two endpoints: `GET /api/oauth/codex/start` (initiates Codex device-code flow, returns `user_code` + `verification_uri`) and `GET /api/oauth/codex/poll?device_code=X` (SSE for polling token endpoint). Successful poll writes credentials to `~/.hermes/auth.json` under `credential_pool.openai-codex`. Onboarding wizard adds a "Sign in with ChatGPT" path. Idempotent: existing OAuth credential entries are updated in place; new ones use `uuid.uuid4().hex[:8]` with retry-on-collision (3 attempts). (`api/oauth.py`, `api/routes.py`, `static/onboarding.js`, `static/i18n.js`, `static/index.html`, `static/style.css`) @bergeouss — PR #1402
|
||||
|
||||
### Fixed
|
||||
- **Named custom provider routing in model picker — `@custom:NAME:model` form preserved** (#557 follow-up to #1390) — when the model picker iterated `custom_providers` entries with a `name` field (e.g. `[{name: "sub2api", base_url, models: [...]}]`), the option IDs were stored as bare model strings. On chat start, the backend resolved those bare strings through the active/default provider, silently routing the request to the wrong endpoint (e.g. DeepSeek instead of the user's selected `sub2api` proxy). Now the picker prefixes IDs with `@<slug>:<model>` whenever the active provider differs from the named slug, so `_resolve_compatible_session_model_state` (added by #1390) routes through the correct named provider. The frontend `_findModelInDropdown` already strips `@provider:` prefixes during normalization, so legacy `localStorage["hermes-webui-model"]` values with bare IDs continue to resolve. 5 new tests across `test_issue1106_custom_providers_models.py`, `test_provider_mismatch.py`, `test_security_redaction.py`. (`api/config.py`) @Thanatos-Z — PR #1415
|
||||
|
||||
### Changed (Opus pre-release advisor)
|
||||
- **`api/oauth.py::_write_auth_json` chmod 0600 BEFORE rename** — `tmp.replace()` preserves the temp file's umask-derived mode (commonly 0644 or 0664). `auth.json` contains OAuth access/refresh tokens; on shared systems those tokens landed world-readable through the temp-file→rename window. Fix sets `tmp.chmod(0o600)` before the atomic rename, with a `try/except OSError` that logs but doesn't abort if chmod fails on filesystems that don't support POSIX modes. The `api.startup::fix_credential_permissions` sweep also catches this on next process start as belt-and-suspenders. (`api/oauth.py`, `tests/test_v050257_opus_followups.py`)
|
||||
|
||||
- **`_handle_cron_history` and `_handle_cron_run_detail` regex-validate `job_id`** — the `_checkpoint_root() / ws_hash / checkpoint` path-traversal vector caught in v0.50.255 (#1405) had a sibling here: `CRON_OUT / job_id / *.md`. `Path() / "../escape"` does NOT normalize. While `_handle_cron_run_detail` had a downstream `is_relative_to(CRON_OUT.resolve())` check, `_handle_cron_history` didn't. New regex `^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$` with explicit `.`/`..` rejection at the parameter boundary. Mirrors the rollback fix shape. (`api/routes.py`, `tests/test_v050257_opus_followups.py`)
|
||||
|
||||
- **`_handle_cron_history` clamps `offset` and `limit`** — raw `int(qs.get("offset", ["0"])[0])` raised `ValueError` on `?offset=foo` and surfaced as a generic 500. No upper bound on `limit` either. Now wrapped in `try/except (ValueError, TypeError)` returning a 400 on bad input, and `limit` clamped to `[1, 500]`. (`api/routes.py`)
|
||||
|
||||
|
||||
## [v0.50.256] — 2026-05-01
|
||||
|
||||
### Fixed
|
||||
|
||||
18
api/oauth.py
18
api/oauth.py
@@ -39,10 +39,26 @@ def _read_auth_json():
|
||||
|
||||
|
||||
def _write_auth_json(data):
|
||||
"""Atomically write auth.json via temp-file rename."""
|
||||
"""Atomically write auth.json via temp-file rename.
|
||||
|
||||
SECURITY: auth.json contains OAuth access/refresh tokens. ``tmp.replace()``
|
||||
preserves the temp file's mode (created with the process umask, typically
|
||||
0644 or 0664), NOT the prior auth.json mode. Without an explicit chmod,
|
||||
tokens land world-readable on shared systems. Set 0600 BEFORE the rename
|
||||
so there is no window where the final file is world-readable.
|
||||
(Opus pre-release advisor finding.)
|
||||
"""
|
||||
import os, stat
|
||||
AUTH_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = AUTH_JSON_PATH.with_suffix('.tmp')
|
||||
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
try:
|
||||
tmp.chmod(0o600)
|
||||
except OSError as e:
|
||||
# Best-effort: if chmod fails (e.g. on a filesystem that doesn't
|
||||
# support POSIX modes), don't abort. The startup permission fixer
|
||||
# in api.startup will sweep auth.json on the next process start.
|
||||
logger.warning("Failed to chmod 0600 on %s: %s", tmp, e)
|
||||
tmp.replace(AUTH_JSON_PATH)
|
||||
|
||||
|
||||
|
||||
@@ -3696,13 +3696,26 @@ def _handle_cron_history(handler, parsed):
|
||||
without fetching full output for every run.
|
||||
"""
|
||||
from cron.jobs import OUTPUT_DIR as CRON_OUT
|
||||
import re as _re
|
||||
|
||||
qs = parse_qs(parsed.query)
|
||||
job_id = qs.get("job_id", [""])[0]
|
||||
offset = int(qs.get("offset", ["0"])[0])
|
||||
limit = int(qs.get("limit", ["50"])[0])
|
||||
if not job_id:
|
||||
return j(handler, {"error": "job_id required"}, status=400)
|
||||
# Defense-in-depth: cron job_ids are 12-char hex from the agent's scheduler.
|
||||
# Without validation, a job_id of "../<other>" would let an authenticated
|
||||
# caller enumerate .md filenames in adjacent directories under CRON_OUT's
|
||||
# parent. Mirror the rollback checkpoint id regex shape.
|
||||
# (Opus pre-release advisor finding.)
|
||||
if not _re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}", job_id) or job_id in (".", ".."):
|
||||
return j(handler, {"error": "invalid job_id"}, status=400)
|
||||
# Reject malformed offset/limit instead of letting int() raise ValueError
|
||||
# and surface as a confusing 500. Clamp to safe ranges.
|
||||
try:
|
||||
offset = max(0, int(qs.get("offset", ["0"])[0]))
|
||||
limit = max(1, min(500, int(qs.get("limit", ["50"])[0])))
|
||||
except (ValueError, TypeError):
|
||||
return j(handler, {"error": "offset and limit must be integers"}, status=400)
|
||||
out_dir = CRON_OUT / job_id
|
||||
runs = []
|
||||
total = 0
|
||||
@@ -3726,12 +3739,19 @@ def _handle_cron_history(handler, parsed):
|
||||
def _handle_cron_run_detail(handler, parsed):
|
||||
"""Return full content of a single cron run output file."""
|
||||
from cron.jobs import OUTPUT_DIR as CRON_OUT
|
||||
import re as _re
|
||||
|
||||
qs = parse_qs(parsed.query)
|
||||
job_id = qs.get("job_id", [""])[0]
|
||||
filename = qs.get("filename", [""])[0]
|
||||
if not job_id or not filename:
|
||||
return j(handler, {"error": "job_id and filename required"}, status=400)
|
||||
# Validate job_id shape (defense-in-depth even though the resolve+is_relative_to
|
||||
# check below catches traversal — fail-closed at the parameter boundary so
|
||||
# malformed job_ids return a 400 from the validator rather than a 400 from
|
||||
# the path resolver).
|
||||
if not _re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}", job_id) or job_id in (".", ".."):
|
||||
return j(handler, {"error": "invalid job_id"}, status=400)
|
||||
# Prevent path traversal — resolve and verify it stays within the job's output dir
|
||||
fpath = (CRON_OUT / job_id / filename).resolve()
|
||||
if not fpath.is_relative_to(CRON_OUT.resolve()):
|
||||
|
||||
@@ -9,6 +9,19 @@ import pytest
|
||||
import api.config as config
|
||||
|
||||
|
||||
def _strip_at_prefix(model_id):
|
||||
"""Strip the optional ``@provider:`` (or ``@provider:subname:``) prefix.
|
||||
|
||||
PR #1415 introduced provider-qualified IDs (``@custom:NAME:model``) for
|
||||
named custom providers when the active provider differs. The bare-ID
|
||||
assertions in this test module pre-date that change.
|
||||
"""
|
||||
s = str(model_id or "")
|
||||
if s.startswith("@") and ":" in s:
|
||||
return s.rsplit(":", 1)[1]
|
||||
return s
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_models_cache():
|
||||
"""Invalidate the models TTL cache before and after every test in this file."""
|
||||
@@ -97,7 +110,8 @@ class TestNamedCustomProviderGroup:
|
||||
(g for g in result.get("groups", []) if g["provider"] == "Agent37"), None
|
||||
)
|
||||
assert agent37_group is not None, "Expected an 'Agent37' group"
|
||||
model_ids = [m["id"] for m in agent37_group.get("models", [])]
|
||||
# PR #1415 prefixes IDs with @custom:NAME: when active provider differs from named slug
|
||||
model_ids = [_strip_at_prefix(m["id"]) for m in agent37_group.get("models", [])]
|
||||
assert "my-llm" in model_ids, (
|
||||
f"Expected 'my-llm' in Agent37 group models, got {model_ids}"
|
||||
)
|
||||
@@ -129,7 +143,8 @@ class TestNamedCustomProviderGroup:
|
||||
assert len(agent37_groups) == 1, (
|
||||
f"Expected exactly one 'Agent37' group, got {len(agent37_groups)}"
|
||||
)
|
||||
model_ids = [m["id"] for m in agent37_groups[0].get("models", [])]
|
||||
# PR #1415 prefixes IDs with @custom:NAME: when active provider differs from named slug
|
||||
model_ids = [_strip_at_prefix(m["id"]) for m in agent37_groups[0].get("models", [])]
|
||||
assert "model-a" in model_ids
|
||||
assert "model-b" in model_ids
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ def _models_with_cfg(model_cfg=None, custom_providers=None, active_provider=None
|
||||
|
||||
|
||||
def _all_model_ids(result):
|
||||
"""Extract all model IDs from all groups."""
|
||||
"""Extract all model IDs from all groups (raw form — may include @provider: prefix)."""
|
||||
ids = []
|
||||
for g in result.get("groups", []):
|
||||
for m in g.get("models", []):
|
||||
@@ -43,6 +43,26 @@ def _all_model_ids(result):
|
||||
return ids
|
||||
|
||||
|
||||
def _strip_at_prefix(model_id):
|
||||
"""Strip the optional ``@provider:`` (or ``@provider:subname:``) prefix
|
||||
from a model id so legacy assertions can compare against the bare form.
|
||||
|
||||
PR #1415 introduced provider-qualified IDs (``@custom:NAME:model``) for
|
||||
named custom providers when the active provider differs. The bare-ID
|
||||
assertions in this test module pre-date that change and need normalization
|
||||
to keep checking the same invariant: "does model X appear in the picker".
|
||||
"""
|
||||
s = str(model_id or "")
|
||||
if s.startswith("@") and ":" in s:
|
||||
return s.rsplit(":", 1)[1]
|
||||
return s
|
||||
|
||||
|
||||
def _all_model_ids_bare(result):
|
||||
"""Same as _all_model_ids but with @provider: prefixes stripped."""
|
||||
return [_strip_at_prefix(mid) for mid in _all_model_ids(result)]
|
||||
|
||||
|
||||
def _group_for(result, provider_name):
|
||||
"""Get a group by provider name."""
|
||||
for g in result.get("groups", []):
|
||||
@@ -72,7 +92,7 @@ class TestCustomProvidersModelsDict:
|
||||
}
|
||||
],
|
||||
)
|
||||
ids = _all_model_ids(result)
|
||||
ids = _all_model_ids_bare(result)
|
||||
for expected in ["unsloth-qwen3.6-35b-a3b", "gemma4-26b", "qwen3.5-27b", "qwen3-coder-30b"]:
|
||||
assert expected in ids, f"Expected '{expected}' in model IDs, got {ids}"
|
||||
|
||||
@@ -91,7 +111,7 @@ class TestCustomProvidersModelsDict:
|
||||
}
|
||||
],
|
||||
)
|
||||
ids = _all_model_ids(result)
|
||||
ids = _all_model_ids_bare(result)
|
||||
assert "llama-3-8b" in ids
|
||||
assert "mistral-7b" in ids
|
||||
|
||||
@@ -111,7 +131,7 @@ class TestCustomProvidersModelsDict:
|
||||
}
|
||||
],
|
||||
)
|
||||
ids = _all_model_ids(result)
|
||||
ids = _all_model_ids_bare(result)
|
||||
assert ids.count("base-model") == 1, f"'base-model' should appear exactly once, got {ids.count('base-model')}"
|
||||
assert "other-model" in ids
|
||||
|
||||
@@ -145,7 +165,7 @@ class TestCustomProvidersModelsDict:
|
||||
}
|
||||
],
|
||||
)
|
||||
ids = _all_model_ids(result)
|
||||
ids = _all_model_ids_bare(result)
|
||||
assert "only-model" in ids
|
||||
|
||||
def test_non_string_models_keys_are_skipped(self):
|
||||
@@ -164,7 +184,7 @@ class TestCustomProvidersModelsDict:
|
||||
}
|
||||
],
|
||||
)
|
||||
ids = _all_model_ids(result)
|
||||
ids = _all_model_ids_bare(result)
|
||||
assert "valid-model" in ids
|
||||
assert "another-valid" in ids
|
||||
|
||||
@@ -189,8 +209,10 @@ class TestCustomProvidersModelsDict:
|
||||
group_b = _group_for(result, "Server-B")
|
||||
assert group_a is not None, "Server-A group missing"
|
||||
assert group_b is not None, "Server-B group missing"
|
||||
ids_a = [m["id"] for m in group_a["models"]]
|
||||
ids_b = [m["id"] for m in group_b["models"]]
|
||||
# PR #1415 prefixes model IDs with @custom:NAME: when the active provider
|
||||
# is different from the named slug — strip for the bare-id invariant.
|
||||
ids_a = [_strip_at_prefix(m["id"]) for m in group_a["models"]]
|
||||
ids_b = [_strip_at_prefix(m["id"]) for m in group_b["models"]]
|
||||
assert "model-a1" in ids_a and "model-a2" in ids_a
|
||||
assert "model-b1" in ids_b and "model-b2" in ids_b
|
||||
# No cross-contamination
|
||||
|
||||
133
tests/test_v050257_opus_followups.py
Normal file
133
tests/test_v050257_opus_followups.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Regression tests for v0.50.257 Opus pre-release follow-ups (#1402 + #1415).
|
||||
|
||||
The v0.50.257 batch had four findings on PR #1402:
|
||||
|
||||
1. MUST-FIX (security) — `api/oauth.py::_write_auth_json` used `tmp.replace()`
|
||||
which preserves the temp file's umask-derived mode (commonly 0644 or 0664).
|
||||
`auth.json` contains OAuth access/refresh tokens; on shared systems those
|
||||
tokens landed world-readable. Fix: `tmp.chmod(0o600)` BEFORE rename.
|
||||
|
||||
2. SHOULD-FIX (defense-in-depth) — `_handle_cron_history` and
|
||||
`_handle_cron_run_detail` accepted `job_id` as a path component without
|
||||
validation. `Path() / "../escape"` does not normalize, mirroring the
|
||||
rollback path-traversal vector caught in v0.50.255. Fix: regex validation
|
||||
that rejects `/`, `..`, `.`.
|
||||
|
||||
3. SHOULD-FIX — `_handle_cron_history` parsed `offset`/`limit` via raw
|
||||
`int()`, so `?offset=foo` raised `ValueError` and surfaced as a generic
|
||||
500 instead of a clean 400. Also no upper bound on `limit` (DoS via
|
||||
`?limit=999999999`). Fix: try/except + clamp to safe ranges.
|
||||
|
||||
4. NIT — also propagate the cron `job_id` validation regex to make the
|
||||
pattern explicit at the parameter boundary.
|
||||
|
||||
PR #1415 follow-up: 8 pre-existing tests in test_issue1106 and
|
||||
test_custom_provider_display_name asserted bare model IDs but #1415 changes
|
||||
the named-custom-provider IDs to `@custom:NAME:model` form when active
|
||||
provider differs. Tests updated to use `_strip_at_prefix` helper to keep
|
||||
checking the same invariant ("does model X appear in the picker") in the
|
||||
new shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
# ── 1: auth.json permission fix (chmod 0600 before rename) ───────────────────
|
||||
|
||||
|
||||
def test_oauth_write_auth_json_uses_chmod_0600_before_rename(monkeypatch, tmp_path):
|
||||
"""`_write_auth_json` must chmod 0600 BEFORE renaming so tokens never land
|
||||
world-readable. The previous implementation used `tmp.replace()` which
|
||||
preserves the temp file's umask-derived mode."""
|
||||
sys.path.insert(0, str(REPO))
|
||||
import api.oauth as oauth
|
||||
|
||||
# Point AUTH_JSON_PATH at a tmp dir
|
||||
fake_path = tmp_path / "auth.json"
|
||||
monkeypatch.setattr(oauth, "AUTH_JSON_PATH", fake_path)
|
||||
|
||||
# Set a permissive umask so default write would create 0644
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
oauth._write_auth_json({"credential_pool": {"openai-codex": []}})
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
assert fake_path.exists(), "auth.json was not written"
|
||||
mode = stat.S_IMODE(fake_path.stat().st_mode)
|
||||
# The file must be chmod 0600 — owner read/write only.
|
||||
assert mode == 0o600, (
|
||||
f"auth.json permissions are {oct(mode)}, expected 0o600. "
|
||||
f"OAuth tokens (access_token, refresh_token) live in this file. "
|
||||
f"On shared systems, world-readable tokens are a real exposure."
|
||||
)
|
||||
|
||||
|
||||
def test_oauth_write_auth_json_source_calls_chmod():
|
||||
"""Source-level pin: any future change to _write_auth_json that drops the
|
||||
chmod call must be caught even if the runtime test above is skipped on
|
||||
a filesystem that doesn't support POSIX modes."""
|
||||
src = (REPO / "api" / "oauth.py").read_text(encoding="utf-8")
|
||||
assert "tmp.chmod(0o600)" in src, (
|
||||
"_write_auth_json must call tmp.chmod(0o600) before tmp.replace() — "
|
||||
"without it, OAuth tokens land world-readable on shared systems."
|
||||
)
|
||||
|
||||
|
||||
# ── 2: cron history job_id path-traversal validation ────────────────────────
|
||||
|
||||
|
||||
def test_cron_history_rejects_traversal_in_job_id():
|
||||
"""`_handle_cron_history` and `_handle_cron_run_detail` must regex-validate
|
||||
job_id at the parameter boundary. Mirrors the rollback regex shape from
|
||||
v0.50.255."""
|
||||
src = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
|
||||
# Both handlers must call the validator
|
||||
history_idx = src.find("def _handle_cron_history(")
|
||||
detail_idx = src.find("def _handle_cron_run_detail(")
|
||||
assert history_idx != -1, "_handle_cron_history missing"
|
||||
assert detail_idx != -1, "_handle_cron_run_detail missing"
|
||||
|
||||
history_body = src[history_idx : history_idx + 1500]
|
||||
detail_body = src[detail_idx : detail_idx + 1500]
|
||||
|
||||
# Both must include the regex check
|
||||
for body, name in [(history_body, "_handle_cron_history"), (detail_body, "_handle_cron_run_detail")]:
|
||||
assert "_re.fullmatch" in body and "[A-Za-z0-9_-]" in body, (
|
||||
f"{name} must validate job_id via regex — without this, "
|
||||
f"`?job_id=../<other>` enumerates sibling directory contents."
|
||||
)
|
||||
assert 'job_id in (".", "..")' in body, (
|
||||
f"{name} must explicitly reject `.` and `..` in addition to the regex."
|
||||
)
|
||||
|
||||
|
||||
# ── 3: int() bounds checking on offset/limit ────────────────────────────────
|
||||
|
||||
|
||||
def test_cron_history_clamps_offset_and_limit():
|
||||
"""`_handle_cron_history` must catch `ValueError` from int() and clamp
|
||||
`limit` to a sane upper bound. Without this, `?offset=foo` raises a
|
||||
ValueError that surfaces as a confusing 500 from `do_GET`'s exception
|
||||
handler, and `?limit=999999999` would slice through unbounded glob output."""
|
||||
src = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
|
||||
history_idx = src.find("def _handle_cron_history(")
|
||||
body = src[history_idx : history_idx + 1500]
|
||||
assert "(ValueError, TypeError)" in body, (
|
||||
"_handle_cron_history must catch ValueError from int() so malformed "
|
||||
"offset/limit return a clean 400, not a generic 500."
|
||||
)
|
||||
assert "min(500, int(qs.get" in body, (
|
||||
"_handle_cron_history must clamp `limit` to a sane upper bound (500 chosen) "
|
||||
"to prevent DoS via `?limit=999999999`."
|
||||
)
|
||||
Reference in New Issue
Block a user