Skip to content

Commit 809be1b

Browse files
fix: opt-in --debug flag and README security warning (#48)
* fix: opt-in --debug flag and README security warning Replace hardcoded debug=True in app.run() with a --debug CLI flag (default False) so the server starts in production mode unless explicitly requested. Document the dangerous --host 0.0.0.0 plus --debug combination in the README Options section. * Fix: Replaced with AST helpers * fix: address PR review — shared CLI parser, reloader tied to --debug * Fix: Replaced with strict AST checks * Fix: reverse ordeer
1 parent 2380427 commit 809be1b

3 files changed

Lines changed: 141 additions & 31 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ python app.py --port 8080 --host 0.0.0.0
6262
python app.py --base-dir /path/to/claude/projects
6363
```
6464

65+
> **Security warning:** Do not use `--host 0.0.0.0` together with `--debug` on untrusted networks.
66+
> That combination exposes [Werkzeug's interactive debugger](https://werkzeug.palletsprojects.com/en/stable/debug/),
67+
> which allows arbitrary code execution from any browser that can reach the server.
68+
> For typical local browsing, keep the default `--host 127.0.0.1` and omit `--debug`.
69+
6570
### CLI Export
6671

6772
```bash

app.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,19 @@ def index():
3535
return app
3636

3737

38-
if __name__ == "__main__":
38+
def build_cli_parser():
39+
"""CLI argument parser for ``python app.py`` (stdlib only; safe to import in tests)."""
3940
import argparse
4041

4142
parser = argparse.ArgumentParser(description="Claude Code Chat Browser")
4243
parser.add_argument("--port", type=int, default=5000)
4344
parser.add_argument("--host", default="127.0.0.1")
45+
parser.add_argument(
46+
"--debug",
47+
action="store_true",
48+
default=False,
49+
help="Enable Flask/Werkzeug debug mode (never use with --host 0.0.0.0 on untrusted networks).",
50+
)
4451
parser.add_argument("--base-dir", default=None, help="Override Claude projects dir")
4552
parser.add_argument(
4653
"--exclude-rules", "-e",
@@ -49,13 +56,18 @@ def index():
4956
help="Path to exclusion rules file (sensitive sessions are omitted). "
5057
"If omitted, uses ~/.claude-code-chat-browser/exclusion-rules.txt if present.",
5158
)
52-
args = parser.parse_args()
59+
return parser
60+
61+
62+
if __name__ == "__main__":
63+
args = build_cli_parser().parse_args()
5364

5465
app = create_app(base_dir=args.base_dir, exclusion_rules_path=args.exclude_rules)
5566
print(f"Claude Code Chat Browser running at http://{args.host}:{args.port}")
67+
# Reloader follows --debug on Unix only (Werkzeug file watcher, not the interactive debugger).
5668
app.run(
5769
host=args.host,
5870
port=args.port,
59-
debug=True,
60-
use_reloader=(sys.platform != "win32"),
71+
debug=args.debug,
72+
use_reloader=args.debug and (sys.platform != "win32"),
6173
)

tests/test_cli_args.py

Lines changed: 120 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
pytest tests/test_cli_args.py -v
1010
"""
1111

12+
import ast
1213
import sys
1314
import os
1415
import importlib
@@ -20,9 +21,92 @@
2021
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
2122
sys.path.insert(0, REPO_ROOT)
2223

24+
from app import build_cli_parser
2325
from scripts.export import build_parser
2426

2527

28+
def _is_app_run_call(node: ast.AST) -> bool:
29+
if not isinstance(node, ast.Call):
30+
return False
31+
func = node.func
32+
return (
33+
isinstance(func, ast.Attribute)
34+
and func.attr == "run"
35+
and isinstance(func.value, ast.Name)
36+
and func.value.id == "app"
37+
)
38+
39+
40+
def _call_passes_hardcoded_debug_true(call: ast.Call) -> bool:
41+
"""True if this Call passes literal True for debug (kwarg or positional)."""
42+
for kw in call.keywords:
43+
if kw.arg == "debug" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
44+
return True
45+
for arg in call.args:
46+
if isinstance(arg, ast.Constant) and arg.value is True:
47+
return True
48+
return False
49+
50+
51+
def _debug_kwarg_uses_args(call: ast.Call) -> bool:
52+
for kw in call.keywords:
53+
if kw.arg != "debug":
54+
continue
55+
val = kw.value
56+
return (
57+
isinstance(val, ast.Attribute)
58+
and isinstance(val.value, ast.Name)
59+
and val.value.id == "args"
60+
and val.attr == "debug"
61+
)
62+
return False
63+
64+
65+
def _is_args_debug(node: ast.AST) -> bool:
66+
return (
67+
isinstance(node, ast.Attribute)
68+
and isinstance(node.value, ast.Name)
69+
and node.value.id == "args"
70+
and node.attr == "debug"
71+
)
72+
73+
74+
def _is_sys_platform_ne_win32(node: ast.AST) -> bool:
75+
if not isinstance(node, ast.Compare) or len(node.ops) != 1 or len(node.comparators) != 1:
76+
return False
77+
if not isinstance(node.ops[0], ast.NotEq):
78+
return False
79+
left = node.left
80+
if not (
81+
isinstance(left, ast.Attribute)
82+
and isinstance(left.value, ast.Name)
83+
and left.value.id == "sys"
84+
and left.attr == "platform"
85+
):
86+
return False
87+
right = node.comparators[0]
88+
if isinstance(right, ast.Constant):
89+
return right.value == "win32"
90+
return isinstance(right, ast.Str) and right.s == "win32" # py<3.8
91+
92+
93+
def _is_debug_and_platform_guard(node: ast.AST) -> bool:
94+
"""True for ``args.debug and (sys.platform != "win32")`` in either operand order."""
95+
if not isinstance(node, ast.BoolOp) or not isinstance(node.op, ast.And) or len(node.values) != 2:
96+
return False
97+
a, b = node.values
98+
return (_is_args_debug(a) and _is_sys_platform_ne_win32(b)) or (
99+
_is_args_debug(b) and _is_sys_platform_ne_win32(a)
100+
)
101+
102+
103+
def _use_reloader_kwarg_tied_to_debug(call: ast.Call) -> bool:
104+
for kw in call.keywords:
105+
if kw.arg == "use_reloader":
106+
return _is_debug_and_platform_guard(kw.value)
107+
return False
108+
109+
26110
# ---------------------------------------------------------------------------
27111
# export.py argument tests
28112
# ---------------------------------------------------------------------------
@@ -214,62 +298,72 @@ def test_help_exits_zero(self):
214298
# ---------------------------------------------------------------------------
215299

216300
class TestAppArgparse:
217-
"""app.py __main__ block must expose the same flags as cursor's app.py."""
218-
219-
def _build_parser(self) -> argparse.ArgumentParser:
220-
"""Re-create the argparse parser from app.py without importing Flask."""
221-
parser = argparse.ArgumentParser(description="Claude Code Chat Browser")
222-
parser.add_argument("--port", type=int, default=5000)
223-
parser.add_argument("--host", default="127.0.0.1")
224-
parser.add_argument("--base-dir", default=None)
225-
parser.add_argument("--exclude-rules", "-e", default=None,
226-
metavar="PATH", dest="exclude_rules")
227-
return parser
301+
"""app.py CLI must expose the same flags as cursor's app.py."""
228302

229303
def test_host_default_is_localhost(self):
230304
"""Default host must be 127.0.0.1 to match cursor which binds to localhost only."""
231-
parser = self._build_parser()
305+
parser = build_cli_parser()
232306
args = parser.parse_args([])
233307
assert args.host == "127.0.0.1"
234308

235309
def test_host_override(self):
236-
parser = self._build_parser()
310+
parser = build_cli_parser()
237311
args = parser.parse_args(["--host", "127.0.0.1"])
238312
assert args.host == "127.0.0.1"
239313

314+
def test_debug_default_is_false(self):
315+
parser = build_cli_parser()
316+
args = parser.parse_args([])
317+
assert args.debug is False
318+
319+
def test_debug_explicit_true(self):
320+
parser = build_cli_parser()
321+
args = parser.parse_args(["--debug"])
322+
assert args.debug is True
323+
324+
def test_app_py_debug_not_hardcoded_true(self):
325+
"""app.run() must wire debug from args, not a literal True."""
326+
app_path = os.path.join(REPO_ROOT, "app.py")
327+
with open(app_path, encoding="utf-8") as f:
328+
tree = ast.parse(f.read(), filename=app_path)
329+
app_run_calls = [n for n in ast.walk(tree) if _is_app_run_call(n)]
330+
assert app_run_calls, "expected at least one app.run() call in app.py"
331+
assert not any(_call_passes_hardcoded_debug_true(c) for c in app_run_calls)
332+
assert any(_debug_kwarg_uses_args(c) for c in app_run_calls)
333+
240334
def test_port_default(self):
241-
parser = self._build_parser()
335+
parser = build_cli_parser()
242336
args = parser.parse_args([])
243337
assert args.port == 5000
244338

245339
def test_port_override(self):
246-
parser = self._build_parser()
340+
parser = build_cli_parser()
247341
args = parser.parse_args(["--port", "8080"])
248342
assert args.port == 8080
249343

250344
def test_base_dir_default_none(self):
251-
parser = self._build_parser()
345+
parser = build_cli_parser()
252346
args = parser.parse_args([])
253347
assert args.base_dir is None
254348

255349
def test_base_dir_override(self):
256-
parser = self._build_parser()
350+
parser = build_cli_parser()
257351
args = parser.parse_args(["--base-dir", "/tmp/projects"])
258352
assert args.base_dir == "/tmp/projects"
259353

260354
def test_exclude_rules_default_none(self):
261-
parser = self._build_parser()
355+
parser = build_cli_parser()
262356
args = parser.parse_args([])
263357
assert args.exclude_rules is None
264358

265359
def test_exclude_rules_long_form(self):
266-
parser = self._build_parser()
360+
parser = build_cli_parser()
267361
args = parser.parse_args(["--exclude-rules", "/tmp/rules.txt"])
268362
assert args.exclude_rules == "/tmp/rules.txt"
269363

270364
def test_exclude_rules_short_form(self):
271365
"""Cursor's app.py uses -e as the short form; claude must too."""
272-
parser = self._build_parser()
366+
parser = build_cli_parser()
273367
args = parser.parse_args(["-e", "/tmp/rules.txt"])
274368
assert args.exclude_rules == "/tmp/rules.txt"
275369

@@ -294,11 +388,10 @@ def test_app_py_host_default_is_localhost(self):
294388
assert '"127.0.0.1"' in src
295389

296390
def test_app_py_use_reloader_is_platform_aware(self):
297-
"""use_reloader must depend on sys.platform, not be hardcoded False."""
391+
"""use_reloader must be ``args.debug and (sys.platform != \"win32\")``."""
298392
app_path = os.path.join(REPO_ROOT, "app.py")
299-
with open(app_path, "r", encoding="utf-8") as f:
300-
src = f.read()
301-
assert "sys.platform" in src
302-
assert "win32" in src
303-
# Must NOT have unconditional use_reloader=False
304-
assert "use_reloader=False" not in src
393+
with open(app_path, encoding="utf-8") as f:
394+
tree = ast.parse(f.read(), filename=app_path)
395+
app_run_calls = [n for n in ast.walk(tree) if _is_app_run_call(n)]
396+
assert app_run_calls
397+
assert all(_use_reloader_kwarg_tied_to_debug(c) for c in app_run_calls)

0 commit comments

Comments
 (0)