Skip to content

Commit f79cc65

Browse files
AVADSA25Mikarina13claude
authored
fix(review): J1 — real bugs surfaced by post-refactor review sweep (#168)
Five concrete findings from the code-review + security-review pass after the route-extraction series. Each verified against source + pinned by a new test (tests/test_review_fixes_j1.py, 17 tests). 1. SSRF guard on chat URL auto-fetch (CWE-918, routes/chat.py) _enrich_messages auto-fetches URLs found in chat content — the prompt- injection vector. Added _url_host_is_public(): resolves the host and rejects loopback / private / link-local (incl. 169.254.169.254 metadata) / reserved / multicast / non-http. _fetch_url_content now validates pre-fetch AND follows redirects manually (≤5 hops, re-validating each Location) so a public URL can't 30x-redirect to an internal one. Keeps the dashboard_host:0.0.0.0 opt-in safe. 2. UnboundLocalError on POST /api/chat {"tools": false} (routes/chat.py) last_user_text / has_attachment were bound only inside `if use_tools:`, but _build_chat_system_prompt(...) is called with both regardless → opaque 500. Hoisted both before the gate. 3. _enrich_messages repo_dir was one dirname too shallow (routes/chat.py) After the H1 move into routes/, os.path.dirname(abspath(__file__)) resolves to routes/, not the repo root where codec_search.py lives. Now climbs two levels (matches the web_search.py extraction). Was masked only because the dashboard already has repo root on sys.path. 4. _shutdown_services NameError (codec_dashboard.py) The handler declared `global _qchat_conn, _vibe_conn` and read them, but those singletons moved to routes/qchat.py + routes/vibe.py in D1/D2 — they were never module-level names in codec_dashboard anymore, so shutdown raised NameError before closing anything. Now closes them in their real modules. 5. /api/run_code ran unsupported languages as python (routes/vibe_exec.py) ext_map listed java/cpp/sql but cmd_map didn't, so cmd_map.get(lang, [python3.13]) silently fed them to python3.13. Now returns 400 "Unsupported language: X". Also dropped the dead `body.get("filename", ...)` bare expression. Bonus parity: the non-stream post-LLM path now strips a non-allowlisted [SKILL:...] tag from the answer (the streaming path already did) — cosmetic, the execution-gating invariant was already intact on both paths. Full suite: 2,072 passed / 77 skipped (+17 new J1 tests). ruff clean. Co-authored-by: Mickael Farina <farina.mickael@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 02eb11c commit f79cc65

4 files changed

Lines changed: 221 additions & 13 deletions

File tree

codec_dashboard.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,16 +1113,23 @@ async def _shutdown_services():
11131113
_bg_tasks.clear()
11141114
log.info("[SHUTDOWN] All background services stopped")
11151115
import routes._shared as _shared
1116-
global _qchat_conn, _vibe_conn
11171116
# M-5 (PR-4J): get_db() is now per-thread; close all of them via the registry.
11181117
_shared._close_all_db_conns()
1119-
for conn in (_qchat_conn, _vibe_conn): # dashboard-local singletons
1120-
if conn is not None:
1118+
# J1 fix: the qchat / vibe DB singletons moved to their route modules in
1119+
# D1/D2. The old code declared them `global` and read them here, but they
1120+
# were never module-level names in codec_dashboard anymore → the shutdown
1121+
# handler raised NameError before closing anything. Close them where they
1122+
# actually live now.
1123+
import routes.qchat as _qchat_mod
1124+
import routes.vibe as _vibe_mod
1125+
for _mod, _attr in ((_qchat_mod, "_qchat_conn"), (_vibe_mod, "_vibe_conn")):
1126+
_conn = getattr(_mod, _attr, None)
1127+
if _conn is not None:
11211128
try:
1122-
conn.close()
1129+
_conn.close()
11231130
except Exception:
11241131
pass
1125-
_qchat_conn = _vibe_conn = None
1132+
setattr(_mod, _attr, None)
11261133

11271134

11281135
# E2 health → moved to routes/*.py

routes/chat.py

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,51 @@
4747

4848

4949

50+
def _url_host_is_public(url: str) -> bool:
51+
"""SSRF guard: True only if `url` is http(s) AND every IP its host resolves
52+
to is a public, routable address. Rejects loopback / private / link-local
53+
(incl. 169.254.169.254 cloud-metadata) / reserved / multicast / unspecified.
54+
55+
J1 (re-audit, CWE-918): `_enrich_messages` auto-fetches URLs found in chat
56+
content — the prompt-injection vector. Without this an injected link could
57+
drive server-side GETs against `http://127.0.0.1:8083/...` or other local
58+
`~/.codec` services. CODEC is loopback-only by default, but this keeps the
59+
`dashboard_host: 0.0.0.0` opt-in safe. (Residual: DNS-rebinding TOCTOU
60+
between this check and httpx's own resolve is accepted for a local app.)
61+
"""
62+
import ipaddress
63+
import socket
64+
from urllib.parse import urlparse
65+
try:
66+
parsed = urlparse(url)
67+
if parsed.scheme not in ("http", "https"):
68+
return False
69+
host = parsed.hostname
70+
if not host:
71+
return False
72+
infos = socket.getaddrinfo(host, parsed.port or (443 if parsed.scheme == "https" else 80),
73+
proto=socket.IPPROTO_TCP)
74+
if not infos:
75+
return False
76+
for info in infos:
77+
ip = ipaddress.ip_address(info[4][0])
78+
if (ip.is_private or ip.is_loopback or ip.is_link_local
79+
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
80+
return False
81+
return True
82+
except Exception as e:
83+
log.warning(f"URL host validation failed ({url}): {e}")
84+
return False
85+
86+
5087
def _fetch_url_content(url: str, max_chars: int = 8000) -> str:
51-
"""Fetch a URL and return stripped text content."""
88+
"""Fetch a URL and return stripped text content.
89+
90+
SSRF-hardened (J1): the host is validated as public BEFORE the fetch, and
91+
redirects are followed manually (≤5 hops) so each Location is re-validated
92+
— `follow_redirects=True` would let a public URL 30x-redirect to an
93+
internal one, defeating the pre-check.
94+
"""
5295
try:
5396
import httpx
5497
from html.parser import HTMLParser
@@ -70,9 +113,25 @@ def handle_data(self, data):
70113
if stripped:
71114
self.chunks.append(stripped)
72115

73-
r = httpx.get(url, timeout=15, follow_redirects=True,
74-
headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
75-
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"})
116+
headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
117+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}
118+
cur = url
119+
r = None
120+
with httpx.Client(timeout=15, follow_redirects=False) as client:
121+
for _hop in range(5):
122+
if not _url_host_is_public(cur):
123+
log.warning(f"URL fetch blocked (non-public host): {cur}")
124+
return ""
125+
r = client.get(cur, headers=headers)
126+
if r.is_redirect and "location" in r.headers:
127+
cur = str(r.url.join(r.headers["location"]))
128+
continue
129+
break
130+
else:
131+
log.warning(f"URL fetch aborted (too many redirects): {url}")
132+
return ""
133+
if r is None:
134+
return ""
76135
if 'text/html' in r.headers.get('content-type', ''):
77136
parser = _Stripper()
78137
parser.feed(r.text)
@@ -226,7 +285,10 @@ def _enrich_messages(messages: list, config: dict, force_search: bool = False) -
226285
try:
227286
import sys
228287
import os as _os
229-
repo_dir = _os.path.dirname(_os.path.abspath(__file__))
288+
# J1 fix: this module lives in routes/ now, so the repo root (where
289+
# codec_search.py is) is TWO levels up, not one. The pre-extraction
290+
# original was at repo root → single dirname. Match web_search.py.
291+
repo_dir = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))
230292
if repo_dir not in sys.path:
231293
sys.path.insert(0, repo_dir)
232294
from codec_search import search, format_results
@@ -561,10 +623,16 @@ async def chat_completion(request: Request):
561623
correlation_id=secrets.token_hex(6),
562624
)
563625

626+
# Bind before the use_tools gate so the non-stream / system-prompt paths
627+
# below never hit an UnboundLocalError when a client sends {"tools": false}
628+
# (re-audit J1: was a silent opaque 500 — _build_chat_system_prompt is
629+
# called with both names regardless of the tools flag).
630+
last_user_text = ""
631+
has_attachment = False
632+
564633
# ── Tool Calling: check if last user message matches a skill ──
565634
use_tools = body.get("tools", True) # frontend can disable with tools:false
566635
if use_tools:
567-
last_user_text = ""
568636
for m in reversed(messages):
569637
if m.get("role") == "user" and isinstance(m.get("content"), str):
570638
last_user_text = m["content"]
@@ -792,6 +860,14 @@ def _resolve_skill_tag(raw_tag):
792860
)
793861
except Exception:
794862
pass
863+
else:
864+
# J1 parity: a non-allowlisted skill name is never executed
865+
# (the invariant holds via the two branches above) AND its raw
866+
# tag is stripped — the streaming path's _resolve_skill_tag
867+
# already drops disallowed tags; the non-stream path used to
868+
# leave "[SKILL:foo:...]" visible in the chat bubble.
869+
log.info(f"[Chat] LLM tried disallowed skill {s_name!r} (non-stream) — dropping tag")
870+
answer = answer.replace(skill_tag.group(0), "")
795871

796872
return {"response": answer, "model": model}
797873
except Exception as e:

routes/vibe_exec.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,26 @@ async def run_code(request: Request):
5555
body = await request.json()
5656
code = body.get("code", "")
5757
language = body.get("language", "python")
58-
body.get("filename", "script.py")
5958
if not code.strip():
6059
return JSONResponse({"error": "No code"}, status_code=400)
6160
from codec_config import is_dangerous
6261
if is_dangerous(code):
6362
return JSONResponse({"error": "Blocked: code contains dangerous pattern"}, status_code=403)
64-
ext_map = {"python": ".py", "javascript": ".js", "typescript": ".ts", "bash": ".sh", "go": ".go", "rust": ".rs", "java": ".java", "cpp": ".cpp", "swift": ".swift", "ruby": ".rb", "sql": ".sql"}
63+
# J1: reject languages we can't actually run instead of silently feeding a
64+
# .java/.cpp/.sql file to python3.13 (ext_map had more langs than cmd_map).
65+
cmd_template = {
66+
"python": ["python3.13"],
67+
"javascript": ["node"],
68+
"typescript": ["npx", "ts-node"],
69+
"bash": ["bash"],
70+
"go": ["go", "run"],
71+
"rust": ["rustc"], # special-cased below
72+
"swift": ["swift"],
73+
"ruby": ["ruby"],
74+
}
75+
if language not in cmd_template:
76+
return JSONResponse({"error": f"Unsupported language: {language}"}, status_code=400)
77+
ext_map = {"python": ".py", "javascript": ".js", "typescript": ".ts", "bash": ".sh", "go": ".go", "rust": ".rs", "swift": ".swift", "ruby": ".rb"}
6578
ext = ext_map.get(language, ".txt")
6679
tmp = tempfile.NamedTemporaryFile(suffix=ext, delete=False, mode="w")
6780
tmp.write(code)

tests/test_review_fixes_j1.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""J1 — regression tests for the post-refactor review findings.
2+
3+
Covers the real bugs surfaced by the code-review / security-review sweep after
4+
the route-extraction series:
5+
6+
1. SSRF guard on _fetch_url_content (chat URL auto-fetch — the injection vector)
7+
2. UnboundLocalError on POST /api/chat with {"tools": false}
8+
3. _enrich_messages repo_dir is two-levels-up (codec_search lives at repo root)
9+
4. _shutdown_services no longer NameErrors on the moved _qchat_conn/_vibe_conn
10+
5. /api/run_code rejects unsupported languages instead of running them as python
11+
"""
12+
from __future__ import annotations
13+
14+
import asyncio
15+
import inspect
16+
import sys
17+
from pathlib import Path
18+
19+
import pytest
20+
21+
_REPO = Path(__file__).resolve().parents[1]
22+
if str(_REPO) not in sys.path:
23+
sys.path.insert(0, str(_REPO))
24+
25+
26+
# ── 1. SSRF guard ──────────────────────────────────────────────────────────
27+
class TestSSRFGuard:
28+
@pytest.mark.parametrize("url", [
29+
"http://127.0.0.1:8083/v1/chat/completions", # local LLM
30+
"http://localhost/admin",
31+
"http://169.254.169.254/latest/meta-data/", # cloud metadata
32+
"http://192.168.1.10/x", # private LAN
33+
"http://10.0.0.5/x", # private
34+
"http://[::1]/x", # ipv6 loopback
35+
"ftp://example.com/x", # non-http scheme
36+
"file:///etc/passwd", # file scheme
37+
"http://0.0.0.0/x", # unspecified
38+
])
39+
def test_blocks_internal_and_non_http(self, url):
40+
import routes.chat as c
41+
assert c._url_host_is_public(url) is False, f"{url} should be blocked"
42+
43+
def test_allows_public_numeric(self):
44+
import routes.chat as c
45+
# 1.1.1.1 is a public, routable address — no DNS needed.
46+
assert c._url_host_is_public("https://1.1.1.1/") is True
47+
48+
def test_fetch_returns_empty_for_blocked_host(self):
49+
"""_fetch_url_content must short-circuit to '' for a non-public host —
50+
no httpx call is made (we'd get a connection, not a block, otherwise)."""
51+
import routes.chat as c
52+
assert c._fetch_url_content("http://127.0.0.1:8083/secret") == ""
53+
54+
55+
# ── 2. tools:false must not UnboundLocalError ──────────────────────────────
56+
def test_chat_bindings_hoisted_before_use_tools_gate():
57+
import routes.chat as c
58+
src = inspect.getsource(c.chat_completion)
59+
i_lut = src.index('last_user_text = ""')
60+
i_ha = src.index("has_attachment = False")
61+
i_gate = src.index("if use_tools:")
62+
assert i_lut < i_gate, "last_user_text must be bound before the use_tools gate"
63+
assert i_ha < i_gate, "has_attachment must be bound before the use_tools gate"
64+
65+
66+
# ── 3. _enrich_messages repo_dir resolves to repo ROOT (two dirnames) ──────
67+
def test_enrich_messages_repo_dir_is_two_levels_up():
68+
src = (_REPO / "routes" / "chat.py").read_text()
69+
# the codec_search sys.path insert must climb to the repo root, not routes/
70+
assert "_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))" in src
71+
72+
73+
# ── 4. shutdown handler runs clean (no NameError on moved singletons) ──────
74+
def test_shutdown_services_no_nameerror():
75+
import codec_dashboard as cd
76+
# Must complete without raising NameError for _qchat_conn / _vibe_conn.
77+
asyncio.run(cd._shutdown_services())
78+
79+
80+
def test_dashboard_no_dead_global_singletons():
81+
src = (_REPO / "codec_dashboard.py").read_text()
82+
assert "global _qchat_conn, _vibe_conn" not in src, (
83+
"dead `global _qchat_conn, _vibe_conn` should be gone — they live in "
84+
"routes/qchat.py + routes/vibe.py now"
85+
)
86+
87+
88+
# ── 5. /api/run_code rejects unsupported languages ─────────────────────────
89+
class _FakeReq:
90+
def __init__(self, payload):
91+
self._payload = payload
92+
93+
async def json(self):
94+
return self._payload
95+
96+
97+
def test_run_code_rejects_unsupported_language():
98+
import routes.vibe_exec as ve
99+
resp = asyncio.run(ve.run_code(_FakeReq({"code": "SELECT 1;", "language": "sql"})))
100+
# JSONResponse with 400 — sql isn't runnable, must not fall through to python3.13
101+
assert getattr(resp, "status_code", None) == 400
102+
103+
104+
def test_run_code_still_accepts_python():
105+
import routes.vibe_exec as ve
106+
# empty-code guard returns 400 too, but a real python snippet must NOT be
107+
# rejected as "unsupported language" — assert the body differs.
108+
resp = asyncio.run(ve.run_code(_FakeReq({"code": "", "language": "python"})))
109+
# empty code → 400 "No code", NOT "Unsupported language"
110+
import json as _json
111+
body = _json.loads(bytes(resp.body).decode())
112+
assert "Unsupported language" not in body.get("error", "")

0 commit comments

Comments
 (0)