Skip to content

Commit 85d765f

Browse files
batmnnncursoragent
andcommitted
Harden serve defaults and close XSS/path injection sinks.
Make tracer serve loopback-only with opt-in CORS and request/batch caps, escape untrusted report/scan content (including sankey/viz), and allowlist watch sink filenames so local telemetry cannot traverse the filesystem. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 813821e commit 85d765f

17 files changed

Lines changed: 504 additions & 51 deletions

File tree

‎.gitignore‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,15 @@ notebooks/data/
3535

3636
# Node (JS package)
3737
node_modules/
38+
39+
# Secrets / credentials (never commit)
40+
.env
41+
.env.*
42+
!.env.example
43+
*.pem
44+
*.key
45+
credentials.json
46+
cloud.json
47+
48+
# Local agent/security tooling
49+
.superstack/

‎AGENTS.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,9 @@ router = tracer.load_router(".tracer", embedder=my_embedder)
155155

156156
### Pattern 4: HTTP server (zero deps)
157157
```bash
158-
tracer serve .tracer --port 8000
158+
tracer serve .tracer --port 8000 # binds 127.0.0.1 by default (no auth)
159159
# POST /predict {"embedding": [...]} → {"label", "decision", "accept_score"}
160+
# Only use --host 0.0.0.0 behind a reverse proxy with TLS/auth. .tracer artifacts are trusted code (joblib/pickle).
160161
```
161162

162163
---

‎SECURITY.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,18 @@ own API keys; issues in third-party dependencies, model providers, or your own
4545
deployment configuration are out of scope here — please report those to the
4646
relevant vendor.
4747

48+
## Trust model (operators)
49+
50+
A few boundaries that matter when deploying Tracer:
51+
52+
- **`.tracer` artifacts are trusted code.** `pipeline.joblib` is loaded with
53+
joblib/pickle. Only load artifacts you produced (or otherwise fully trust).
54+
Treat a foreign `.tracer/` directory like an untrusted binary.
55+
- **`tracer serve` has no authentication.** It binds to `127.0.0.1` by default.
56+
If you bind to `0.0.0.0` (or otherwise expose the port), put a reverse proxy
57+
with TLS and auth in front. CORS is off unless you pass `--cors-origin`.
58+
- **`tracer.watch` / `@tracer-llm/watch` may exfiltrate prompts.** When
59+
`TRACER_CLOUD_KEY` (or an OTLP endpoint) is set, full input/output text is
60+
sent to that destination. Keep those env vars under your control.
61+
4862
Thank you for helping keep Tracer and its users safe.

‎docs/cli.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,10 @@ tracer serve [<artifact-dir>] [--host <addr>] [--port <int>]
339339
| Argument | Default | Description |
340340
|----------|---------|-------------|
341341
| `artifact-dir` | `.tracer` | Artifact directory |
342-
| `--host` | `0.0.0.0` | Bind address |
342+
| `--host` | `127.0.0.1` | Bind address (loopback; use `0.0.0.0` only behind a reverse proxy with auth) |
343+
| `--cors-origin` | off | If set, emit `Access-Control-Allow-Origin` (e.g. `https://app.example.com` or `*`) |
344+
| `--max-body-bytes` | `16777216` | Reject larger request bodies |
345+
| `--max-batch` | `10000` | Reject `/predict_batch` with more rows |
343346
| `--port` | `8000` | Listen port |
344347

345348
**Endpoints:**

‎docs/javascript.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ TRACER reads `traces.jsonl` and auto-discovers `traces.npy` (same stem, `.npy` e
8181
tracer serve .tracer --port 8000
8282
```
8383

84-
Run this as a sidecar next to your Node app, same server, same docker-compose, same machine. It binds to `0.0.0.0:8000` by default.
84+
Run this as a sidecar next to your Node app, same server, same docker-compose, same machine. It binds to `127.0.0.1:8000` by default (pass `--host 0.0.0.0` only behind a reverse proxy with auth).
8585

8686
---
8787

‎js/src/index.ts‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -372,16 +372,35 @@ function getEnv(name: string): string | undefined {
372372
return g.process?.env?.[name];
373373
}
374374

375+
const WATCH_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
376+
377+
/** Allowlist watcher names used as filenames (no path separators / traversal). */
378+
export function safeWatchName(name: string): string {
379+
if (typeof name !== "string" || !WATCH_NAME_RE.test(name)) {
380+
throw new Error(
381+
`invalid watch name ${JSON.stringify(name)}: use 1-128 chars of [A-Za-z0-9._-], starting with alphanumeric`,
382+
);
383+
}
384+
return name;
385+
}
386+
375387
/** Append spans as JSONL to `<dir>/<name>.jsonl`. No network, no key. */
376388
export class LocalFileSink implements Sink {
377389
readonly path: string;
378390
constructor(name: string, dir: string = ".tracer/watch") {
379-
this.path = path.join(dir, `${name}.jsonl`);
391+
const safe = safeWatchName(name);
392+
const root = path.resolve(dir);
380393
try {
381-
fs.mkdirSync(dir, { recursive: true });
394+
fs.mkdirSync(root, { recursive: true });
382395
} catch (e) {
383-
debugLog(`could not create dir ${dir}: ${String(e)}`);
396+
debugLog(`could not create dir ${root}: ${String(e)}`);
397+
}
398+
const full = path.resolve(root, `${safe}.jsonl`);
399+
const rel = path.relative(root, full);
400+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
401+
throw new Error(`watch path escapes directory: ${JSON.stringify(name)}`);
384402
}
403+
this.path = full;
385404
}
386405

387406
emit(span: GenAISpan): void {

‎js/test/watch.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ describe("GenAISpan", () => {
7171

7272
// ----- LocalFileSink --------------------------------------------------------- //
7373
describe("LocalFileSink", () => {
74+
it("rejects unsafe watch names (allowlist)", () => {
75+
const dir = tmpDir();
76+
for (const bad of ["../evil", "..\\evil", "a/b", "..", "", "has space", "-leading"]) {
77+
expect(() => new LocalFileSink(bad, dir)).toThrow(/invalid watch name/);
78+
}
79+
});
80+
7481
it("writes JSONL", () => {
7582
const dir = tmpDir();
7683
const sink = new LocalFileSink("unit", dir);

‎src/tracer/analysis/html_report.py‎

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,18 @@
33
from __future__ import annotations
44

55
import json
6+
from html import escape as _html_escape
67
from pathlib import Path
78
from typing import Union
89

10+
# Pin Plotly (no "latest"); only injected when a sankey div is rendered.
11+
_PLOTLY_CDN = "https://cdn.plot.ly/plotly-2.35.2.min.js"
12+
13+
14+
def _esc(value) -> str:
15+
"""HTML-escape dynamic report fields (XSS-safe)."""
16+
return _html_escape("" if value is None else str(value), quote=True)
17+
918

1019
_CSS = """
1120
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -222,11 +231,26 @@ def generate_html_report(
222231
method = manifest_raw.get("selected_method") or "none"
223232
coverage = manifest_raw.get("coverage_cal")
224233
ta = manifest_raw.get("teacher_agreement_cal")
225-
n_traces = manifest_raw.get("n_traces", 0)
226-
n_labels = len(manifest_raw.get("label_space", []))
227-
emb_dim = manifest_raw.get("embedding_dim")
228-
target_ta = manifest_raw.get("target_teacher_agreement", 0.90)
229-
method_cls = _METHOD_BADGE.get(method, "b-gray")
234+
# Coerce numerics — manifest fields are untrusted if the artifact is.
235+
try:
236+
n_traces = int(manifest_raw.get("n_traces", 0) or 0)
237+
except (TypeError, ValueError):
238+
n_traces = 0
239+
label_space = manifest_raw.get("label_space") or []
240+
n_labels = len(label_space) if isinstance(label_space, list) else 0
241+
try:
242+
emb_dim = int(manifest_raw["embedding_dim"]) if manifest_raw.get("embedding_dim") is not None else None
243+
except (TypeError, ValueError):
244+
emb_dim = None
245+
try:
246+
target_ta = float(manifest_raw.get("target_teacher_agreement", 0.90) or 0.90)
247+
except (TypeError, ValueError):
248+
target_ta = 0.90
249+
# Only allow known badge classes (manifest method is untrusted input).
250+
method_cls = _METHOD_BADGE.get(method) or "b-gray"
251+
if method_cls not in ("b-green", "b-blue", "b-purple", "b-gray"):
252+
method_cls = "b-gray"
253+
emb_dim_disp = str(emb_dim) if emb_dim is not None else "?"
230254

231255
cov_exact = (coverage or 0) * 100
232256
defer_exact = 100 - cov_exact
@@ -261,10 +285,7 @@ def generate_html_report(
261285
<meta charset="utf-8">
262286
<meta name="viewport" content="width=device-width, initial-scale=1">
263287
<title>Tracer routing report</title>
264-
<link rel="preconnect" href="https://fonts.googleapis.com">
265-
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Manrope:wght@500;700;800&display=swap" rel="stylesheet">
266288
<style>{_CSS}</style>
267-
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
268289
</head>
269290
<body>
270291
<div class="page">
@@ -278,7 +299,7 @@ def generate_html_report(
278299
<h1>Routing Policy</h1>
279300
<div class="subtitle">
280301
{n_traces:,} traces &nbsp;·&nbsp; {n_labels} labels
281-
&nbsp;·&nbsp; {emb_dim}-dim embeddings
302+
&nbsp;·&nbsp; {_esc(emb_dim_disp)}-dim embeddings
282303
&nbsp;·&nbsp; target TA = {target_ta:.0%}
283304
</div>
284305
@@ -296,7 +317,7 @@ def generate_html_report(
296317
<div class="card purple">
297318
<div class="card-label">Method</div>
298319
<div class="card-value" style="font-size:1.2rem;margin-top:10px">
299-
<span class="badge {method_cls}">{method.upper()}</span>
320+
<span class="badge {method_cls}">{_esc(method.upper())}</span>
300321
</div>
301322
<div class="card-sub">selected pipeline</div>
302323
</div>
@@ -355,6 +376,7 @@ def generate_html_report(
355376
from tracer.analysis.sankey import generate_sankey_div
356377
sankey_div = generate_sankey_div(artifact_dir)
357378
if sankey_div:
379+
html += f'<script src="{_PLOTLY_CDN}"></script>\n'
358380
html += f"""
359381
<div class="section">
360382
<h2>Routing Flow</h2>
@@ -389,16 +411,16 @@ def generate_html_report(
389411
<tbody>
390412
"""
391413
for s in sorted(label_slices, key=lambda x: -x["handled_rate"]):
392-
label = s["slice_name"].replace("label:", "")
414+
label = _esc(s["slice_name"].replace("label:", ""))
393415
hr_v = s["handled_rate"]
394416
ta_s = s.get("teacher_agreement_handled")
395417
ta_str = f"{ta_s:.1%}" if ta_s is not None else "-"
396418
html += (
397419
f'<tr><td><code style="color:#8b949e;font-size:.82rem">{label}</code></td>'
398420
f'<td><b style="color:#f0f6fc">{hr_v:.1%}</b></td>'
399421
f'<td>{_bar_html(hr_v)}</td>'
400-
f'<td style="color:#8b949e">{s["count"]}</td>'
401-
f'<td style="color:#8b949e">{ta_str}</td></tr>\n'
422+
f'<td style="color:#8b949e">{int(s["count"])}</td>'
423+
f'<td style="color:#8b949e">{_esc(ta_str)}</td></tr>\n'
402424
)
403425
html += " </tbody>\n </table>\n</div>\n"
404426

@@ -408,10 +430,10 @@ def generate_html_report(
408430
html += '<tr><th>Bucket</th><th>Coverage</th><th style="width:130px"></th><th>Count</th></tr>\n'
409431
for s in length_slices:
410432
html += (
411-
f'<tr><td>{s["slice_name"]}</td>'
433+
f'<tr><td>{_esc(s["slice_name"])}</td>'
412434
f'<td><b style="color:#f0f6fc">{s["handled_rate"]:.1%}</b></td>'
413435
f'<td>{_bar_html(s["handled_rate"])}</td>'
414-
f'<td style="color:#8b949e">{s["count"]}</td></tr>\n'
436+
f'<td style="color:#8b949e">{int(s["count"])}</td></tr>\n'
415437
)
416438
html += '</table>\n</div>\n'
417439

@@ -423,14 +445,14 @@ def generate_html_report(
423445
hs = _score_str(p.get("handled_score"))
424446
ds = _score_str(p.get("deferred_score"))
425447
html += f"""<div class="pair">
426-
<div class="pair-intent">{p["teacher_label"]}</div>
448+
<div class="pair-intent">{_esc(p["teacher_label"])}</div>
427449
<div class="pair-row">
428450
<span class="pair-tag pt-local">SURROGATE</span>
429-
<span class="pair-text">{p["handled_preview"]}</span>{hs}
451+
<span class="pair-text">{_esc(p["handled_preview"])}</span>{hs}
430452
</div>
431453
<div class="pair-row">
432454
<span class="pair-tag pt-deferred">→ LLM</span>
433-
<span class="pair-text">{p["deferred_preview"]}</span>{ds}
455+
<span class="pair-text">{_esc(p["deferred_preview"])}</span>{ds}
434456
</div>
435457
</div>\n"""
436458
html += '</div>\n'
@@ -443,18 +465,18 @@ def generate_html_report(
443465
for ex in handled_ex[:6]:
444466
score_str = f'<span class="ex-score">score {ex["accept_score"]:.2f}</span>' if ex.get("accept_score") else ""
445467
html += (f'<div class="ex-item handled">'
446-
f'<div class="ex-text">{ex["input_preview"]}</div>'
468+
f'<div class="ex-text">{_esc(ex["input_preview"])}</div>'
447469
f'<div class="ex-meta">'
448-
f'<span class="ex-label">{ex["teacher_label"]}</span>'
470+
f'<span class="ex-label">{_esc(ex["teacher_label"])}</span>'
449471
f'{score_str}</div></div>\n')
450472
html += '</div>\n'
451473

452474
html += '<div class="ex-col">\n<h3>Deferred to teacher</h3>\n'
453475
for ex in deferred_ex[:6]:
454476
html += (f'<div class="ex-item deferred">'
455-
f'<div class="ex-text">{ex["input_preview"]}</div>'
477+
f'<div class="ex-text">{_esc(ex["input_preview"])}</div>'
456478
f'<div class="ex-meta">'
457-
f'<span class="ex-label">{ex["teacher_label"]}</span>'
479+
f'<span class="ex-label">{_esc(ex["teacher_label"])}</span>'
458480
f'</div></div>\n')
459481
html += '</div>\n'
460482

@@ -470,7 +492,7 @@ def generate_html_report(
470492
prev_bar = int(d["previous_handled_rate"] * 60)
471493
cur_bar = int(d["current_handled_rate"] * 60)
472494
html += (
473-
f'<tr><td><code style="color:#8b949e;font-size:.82rem">{d["label"]}</code></td>'
495+
f'<tr><td><code style="color:#8b949e;font-size:.82rem">{_esc(d["label"])}</code></td>'
474496
f'<td style="color:#8b949e">{d["previous_handled_rate"]:.1%}'
475497
f'<div class="delta-bar-bg"><div class="delta-bar-fill" style="width:{prev_bar}px"></div></div></td>'
476498
f'<td style="color:#f0f6fc">{d["current_handled_rate"]:.1%}'

‎src/tracer/analysis/sankey.py‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,20 @@
33
from __future__ import annotations
44

55
import json
6+
import re
67
from pathlib import Path
78
from typing import Optional, Union
89

10+
# Labels are embedded into Plotly HTML/JS. Strip markup metacharacters so a
11+
# poisoned teacher label cannot break out of the inline <script> payload.
12+
_UNSAFE_LABEL = re.compile(r"""[<>&"'`\\]""")
13+
14+
15+
def _safe_node_label(text: str, *, max_len: int = 120) -> str:
16+
cleaned = _UNSAFE_LABEL.sub("", str(text))
17+
cleaned = "".join(ch for ch in cleaned if ch.isprintable() or ch in " \t")
18+
return cleaned[:max_len] if cleaned else "(label)"
19+
920

1021
_PALETTE = [
1122
"#4f7cac", "#7fb069", "#e0a458", "#c97b84", "#8f7aa7",
@@ -131,15 +142,18 @@ def _build_sankey_figure(manifest: dict, qr: dict, top_k: int, dark: bool):
131142
n_other_labels = len(manifest.get("label_space", [])) - n_top
132143
has_other = (other_handled + other_deferred) > 0.5
133144

134-
node_labels = [s["slice_name"].replace("label:", "").replace("_", " ") for s in top]
145+
node_labels = [
146+
_safe_node_label(s["slice_name"].replace("label:", "").replace("_", " "))
147+
for s in top
148+
]
135149
if has_other:
136-
node_labels.append(f"other ({n_other_labels} labels)")
150+
node_labels.append(_safe_node_label(f"other ({n_other_labels} labels)"))
137151
surrogate_idx = len(node_labels)
138152
teacher_idx = surrogate_idx + 1
139153
pct_h = coverage * 100
140154
pct_d = (1 - coverage) * 100
141-
node_labels.append(f"Surrogate ({pct_h:.0f}%)")
142-
node_labels.append(f"Teacher / LLM ({pct_d:.0f}%)")
155+
node_labels.append(_safe_node_label(f"Surrogate ({pct_h:.0f}%)"))
156+
node_labels.append(_safe_node_label(f"Teacher / LLM ({pct_d:.0f}%)"))
143157

144158
if dark:
145159
_palette_dark = [

‎src/tracer/cli/main.py‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,14 @@ def _cmd_sankey(args):
174174

175175
def _cmd_serve(args):
176176
from tracer.runtime.serve import serve
177-
serve(artifact_dir=args.artifact_dir, host=args.host, port=args.port)
177+
serve(
178+
artifact_dir=args.artifact_dir,
179+
host=args.host,
180+
port=args.port,
181+
cors_origin=args.cors_origin,
182+
max_body_bytes=args.max_body_bytes,
183+
max_batch=args.max_batch,
184+
)
178185

179186

180187
def _cmd_update(args):
@@ -691,12 +698,29 @@ def main():
691698
p_sankey.add_argument("--no-open", action="store_true",
692699
help="Don't open browser automatically (html only)")
693700

694-
p_serve = sub.add_parser("serve",
695-
help="Start a prediction HTTP server")
701+
p_serve = sub.add_parser(
702+
"serve",
703+
help="Start a prediction HTTP server (loopback by default, no auth)",
704+
)
696705
p_serve.add_argument("artifact_dir", nargs="?", default=".tracer",
697706
help="Artifact directory (default: .tracer)")
698-
p_serve.add_argument("--host", default="0.0.0.0", help="Bind address (default: 0.0.0.0)")
707+
p_serve.add_argument(
708+
"--host", default="127.0.0.1",
709+
help="Bind address (default: 127.0.0.1; use 0.0.0.0 only behind a reverse proxy)",
710+
)
699711
p_serve.add_argument("--port", type=int, default=8000, help="Port (default: 8000)")
712+
p_serve.add_argument(
713+
"--cors-origin", default=None,
714+
help="If set, emit Access-Control-Allow-Origin (e.g. https://app.example.com or *). Off by default.",
715+
)
716+
p_serve.add_argument(
717+
"--max-body-bytes", type=int, default=16 * 1024 * 1024,
718+
help="Reject request bodies larger than this (default: 16MiB)",
719+
)
720+
p_serve.add_argument(
721+
"--max-batch", type=int, default=10_000,
722+
help="Reject /predict_batch with more rows than this (default: 10000)",
723+
)
700724

701725
p_update = sub.add_parser("update",
702726
help="Refit with new traces (continual learning)")

0 commit comments

Comments
 (0)