Eric Xu commited on
Commit
9ea2205
·
unverified ·
1 Parent(s): 2f01b87

Security hardening: rate limiting, no creds in URLs, path traversal fix

Browse files

Critical fixes from security audit:
- Move API key from query params to Authorization header (never logged)
- Add per-IP rate limiting (20 LLM requests/hour)
- Cap parallel workers at 10 to prevent abuse
- Fix Nemotron path traversal (must be within project or /tmp)
- Disable uvicorn access log to prevent credential leakage
- Remove filesystem paths from /api/config response
- Fix Dockerfile: add git for HF Spaces build
- Update privacy claim to accurately describe key handling

Files changed (3) hide show
  1. Dockerfile +2 -1
  2. web/app.py +62 -17
  3. web/static/index.html +18 -24
Dockerfile CHANGED
@@ -1,9 +1,10 @@
1
  FROM python:3.11-slim
2
 
 
 
3
  WORKDIR /app
4
 
5
  # Install dependencies
6
- COPY pyproject.toml .
7
  RUN pip install --no-cache-dir \
8
  "datasets>=4.0.0" \
9
  "huggingface_hub>=0.20.0" \
 
1
  FROM python:3.11-slim
2
 
3
+ RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
4
+
5
  WORKDIR /app
6
 
7
  # Install dependencies
 
8
  RUN pip install --no-cache-dir \
9
  "datasets>=4.0.0" \
10
  "huggingface_hub>=0.20.0" \
web/app.py CHANGED
@@ -138,17 +138,43 @@ def _llm_from_params(api_key: str = "", base_url: str = "", model: str = ""):
138
  )
139
 
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  @app.middleware("http")
142
  async def inject_llm_config(request: Request, call_next):
143
- """Make LLM creds available from query params on any request."""
144
- request.state.api_key = request.query_params.get("api_key", "")
145
- request.state.base_url = request.query_params.get("base_url", "")
146
- request.state.model = request.query_params.get("model", "")
 
 
 
 
 
 
 
 
147
  return await call_next(request)
148
 
149
 
150
  def llm_from_request(request: Request):
151
- """Get LLM client+model from the current request's query params."""
152
  return _llm_from_params(
153
  request.state.api_key, request.state.base_url, request.state.model
154
  )
@@ -210,7 +236,6 @@ async def get_config():
210
  "model": get_model(),
211
  "has_api_key": has_key,
212
  "base_url": os.getenv("LLM_BASE_URL", ""),
213
- "nemotron_path": str(nem_path) if nem_path else None,
214
  "nemotron_available": nem_path is not None,
215
  "is_spaces": IS_SPACES,
216
  "persona_datasets": list(NEMOTRON_DATASETS.keys()),
@@ -233,6 +258,9 @@ class NemotronPathInput(BaseModel):
233
  async def setup_nemotron(input: NemotronPathInput):
234
  """Point to existing data, or download a Nemotron dataset to the given path."""
235
  p = Path(input.path).expanduser().resolve()
 
 
 
236
  hf_name = NEMOTRON_DATASETS.get(input.dataset, NEMOTRON_DATASETS["USA"])
237
 
238
  if (p / "dataset_info.json").exists():
@@ -528,17 +556,25 @@ async def upload_cohort(sid: str, cohort: list[dict]):
528
  # ── SSE streaming endpoints ──────────────────────────────────────────────
529
 
530
  @app.get("/api/evaluate/stream/{sid}")
531
- async def evaluate_stream(sid: str, parallel: int = 5, bias_calibration: bool = False,
532
- api_key: str = "", base_url: str = "", model: str = ""):
533
  """Run evaluation with Server-Sent Events for real-time progress."""
 
534
  if sid not in sessions:
535
  raise HTTPException(404, "Session not found")
536
  session = sessions[sid]
537
  if not session["cohort"]:
538
  raise HTTPException(400, "No cohort — generate or upload one first")
 
 
 
 
 
 
 
539
 
540
  async def event_generator():
541
- client, mdl = _llm_from_params(api_key, base_url, model)
542
  cohort = session["cohort"]
543
  entity_text = session["entity_text"]
544
  total = len(cohort)
@@ -630,9 +666,9 @@ async def prepare_counterfactual(sid: str, req: CounterfactualRequest):
630
 
631
 
632
  @app.get("/api/counterfactual/stream/{sid}")
633
- async def counterfactual_stream(sid: str, ticket: str,
634
- api_key: str = "", base_url: str = "", model: str = ""):
635
  """Run counterfactual probes with SSE progress."""
 
636
  if sid not in sessions:
637
  raise HTTPException(404, "Session not found")
638
  session = sessions[sid]
@@ -651,8 +687,13 @@ async def counterfactual_stream(sid: str, ticket: str,
651
  max_score = req.max_score
652
  parallel = req.parallel
653
 
 
 
 
 
 
654
  async def event_generator():
655
- client, mdl = _llm_from_params(api_key, base_url, model)
656
  cohort = session["cohort"]
657
  eval_results = session["eval_results"]
658
  cohort_map = {f"{p.get('name','')}_{p.get('user_id','')}": p for p in cohort}
@@ -742,11 +783,12 @@ async def counterfactual_stream(sid: str, ticket: str,
742
 
743
  @app.get("/api/bias-audit/stream/{sid}")
744
  async def bias_audit_stream(
745
- sid: str, probes: str = "framing,authority,order",
746
- sample: int = 10, parallel: int = 5,
747
- api_key: str = "", base_url: str = "", model: str = ""
748
  ):
749
  """Run bias audit probes with SSE progress."""
 
 
750
  if sid not in sessions:
751
  raise HTTPException(404, "Session not found")
752
  session = sessions[sid]
@@ -757,7 +799,10 @@ async def bias_audit_stream(
757
 
758
  async def event_generator():
759
  import random
760
- client, mdl = _llm_from_params(api_key, base_url, model)
 
 
 
761
  cohort = session["cohort"]
762
  entity_text = session["entity_text"]
763
 
@@ -880,4 +925,4 @@ if __name__ == "__main__":
880
  host = "0.0.0.0" if IS_SPACES else "127.0.0.1"
881
  print(f"\n SGO Web Interface")
882
  print(f" http://{host}:{port}\n")
883
- uvicorn.run(app, host=host, port=port)
 
138
  )
139
 
140
 
141
+ # Rate limiting — per-IP request counter
142
+ _rate_limits: dict = {} # ip -> {"count": N, "reset": timestamp}
143
+ RATE_LIMIT_MAX = 20 # LLM requests per window
144
+ RATE_LIMIT_WINDOW = 3600 # 1 hour
145
+
146
+
147
+ def _check_rate_limit(ip: str):
148
+ """Raise 429 if IP has exceeded rate limit."""
149
+ now = time.time()
150
+ entry = _rate_limits.get(ip)
151
+ if not entry or now > entry["reset"]:
152
+ _rate_limits[ip] = {"count": 1, "reset": now + RATE_LIMIT_WINDOW}
153
+ return
154
+ if entry["count"] >= RATE_LIMIT_MAX:
155
+ raise HTTPException(429, f"Rate limit exceeded. Try again in {int(entry['reset'] - now)}s.")
156
+ entry["count"] += 1
157
+
158
+
159
  @app.middleware("http")
160
  async def inject_llm_config(request: Request, call_next):
161
+ """Read LLM creds from Authorization header (not query params those get logged)."""
162
+ # Authorization: Bearer <api_key>|<base_url>|<model> (pipe-separated)
163
+ auth = request.headers.get("authorization", "")
164
+ if auth.startswith("Bearer "):
165
+ parts = auth[7:].split("|", 2)
166
+ request.state.api_key = parts[0] if len(parts) > 0 else ""
167
+ request.state.base_url = parts[1] if len(parts) > 1 else ""
168
+ request.state.model = parts[2] if len(parts) > 2 else ""
169
+ else:
170
+ request.state.api_key = ""
171
+ request.state.base_url = ""
172
+ request.state.model = ""
173
  return await call_next(request)
174
 
175
 
176
  def llm_from_request(request: Request):
177
+ """Get LLM client+model from the current request. Never logs credentials."""
178
  return _llm_from_params(
179
  request.state.api_key, request.state.base_url, request.state.model
180
  )
 
236
  "model": get_model(),
237
  "has_api_key": has_key,
238
  "base_url": os.getenv("LLM_BASE_URL", ""),
 
239
  "nemotron_available": nem_path is not None,
240
  "is_spaces": IS_SPACES,
241
  "persona_datasets": list(NEMOTRON_DATASETS.keys()),
 
258
  async def setup_nemotron(input: NemotronPathInput):
259
  """Point to existing data, or download a Nemotron dataset to the given path."""
260
  p = Path(input.path).expanduser().resolve()
261
+ # Prevent path traversal — must be within project or /tmp
262
+ if not (p.is_relative_to(PROJECT_ROOT) or p.is_relative_to(Path("/tmp"))):
263
+ raise HTTPException(403, "Path must be within the project directory")
264
  hf_name = NEMOTRON_DATASETS.get(input.dataset, NEMOTRON_DATASETS["USA"])
265
 
266
  if (p / "dataset_info.json").exists():
 
556
  # ── SSE streaming endpoints ──────────────────────────────────────────────
557
 
558
  @app.get("/api/evaluate/stream/{sid}")
559
+ async def evaluate_stream(sid: str, request: Request, parallel: int = 5,
560
+ bias_calibration: bool = False):
561
  """Run evaluation with Server-Sent Events for real-time progress."""
562
+ _check_rate_limit(request.client.host)
563
  if sid not in sessions:
564
  raise HTTPException(404, "Session not found")
565
  session = sessions[sid]
566
  if not session["cohort"]:
567
  raise HTTPException(400, "No cohort — generate or upload one first")
568
+ # Cap parallel to prevent abuse
569
+ parallel = min(parallel, 10)
570
+
571
+ # Capture LLM config from request headers before entering async generator
572
+ _api_key = request.state.api_key
573
+ _base_url = request.state.base_url
574
+ _model = request.state.model
575
 
576
  async def event_generator():
577
+ client, mdl = _llm_from_params(_api_key, _base_url, _model)
578
  cohort = session["cohort"]
579
  entity_text = session["entity_text"]
580
  total = len(cohort)
 
666
 
667
 
668
  @app.get("/api/counterfactual/stream/{sid}")
669
+ async def counterfactual_stream(sid: str, ticket: str, request: Request):
 
670
  """Run counterfactual probes with SSE progress."""
671
+ _check_rate_limit(request.client.host)
672
  if sid not in sessions:
673
  raise HTTPException(404, "Session not found")
674
  session = sessions[sid]
 
687
  max_score = req.max_score
688
  parallel = req.parallel
689
 
690
+ _api_key = request.state.api_key
691
+ _base_url = request.state.base_url
692
+ _model = request.state.model
693
+ parallel = min(req.parallel, 10)
694
+
695
  async def event_generator():
696
+ client, mdl = _llm_from_params(_api_key, _base_url, _model)
697
  cohort = session["cohort"]
698
  eval_results = session["eval_results"]
699
  cohort_map = {f"{p.get('name','')}_{p.get('user_id','')}": p for p in cohort}
 
783
 
784
  @app.get("/api/bias-audit/stream/{sid}")
785
  async def bias_audit_stream(
786
+ sid: str, request: Request, probes: str = "framing,authority,order",
787
+ sample: int = 10, parallel: int = 5
 
788
  ):
789
  """Run bias audit probes with SSE progress."""
790
+ _check_rate_limit(request.client.host)
791
+ parallel = min(parallel, 10)
792
  if sid not in sessions:
793
  raise HTTPException(404, "Session not found")
794
  session = sessions[sid]
 
799
 
800
  async def event_generator():
801
  import random
802
+ _api_key = request.state.api_key
803
+ _base_url = request.state.base_url
804
+ _model = request.state.model
805
+ client, mdl = _llm_from_params(_api_key, _base_url, _model)
806
  cohort = session["cohort"]
807
  entity_text = session["entity_text"]
808
 
 
925
  host = "0.0.0.0" if IS_SPACES else "127.0.0.1"
926
  print(f"\n SGO Web Interface")
927
  print(f" http://{host}:{port}\n")
928
+ uvicorn.run(app, host=host, port=port, access_log=False)
web/static/index.html CHANGED
@@ -317,7 +317,7 @@
317
  <div class="step-num" style="border-color:var(--yellow);color:var(--yellow)">!</div>
318
  <div class="step-title">Connect your LLM</div>
319
  </div>
320
- <p class="step-desc">SGO works with any OpenAI-compatible API. Your key is used only for this session and is never stored, logged, or sent anywhere except directly to your chosen API endpoint.</p>
321
  <div class="field">
322
  <label>API key</label>
323
  <input type="password" id="apiKeyInput" placeholder="sk-...">
@@ -600,20 +600,17 @@ let llmBaseUrl = '';
600
  let llmModel = '';
601
 
602
  function llmHeaders() {
603
- return {'Content-Type': 'application/json', 'X-LLM-Key': llmApiKey, 'X-LLM-Base': llmBaseUrl, 'X-LLM-Model': llmModel};
604
- }
605
-
606
- function llmQueryParams() {
607
- const p = new URLSearchParams();
608
- if (llmApiKey) p.set('api_key', llmApiKey);
609
- if (llmBaseUrl) p.set('base_url', llmBaseUrl);
610
- if (llmModel) p.set('model', llmModel);
611
- return p;
612
  }
613
 
614
  function apiUrl(path) {
615
- const q = llmQueryParams().toString();
616
- return q ? `${path}?${q}` : path;
617
  }
618
 
619
  // XSS sanitization helper
@@ -699,7 +696,7 @@ async function setupNemotron() {
699
  try {
700
  const resp = await fetch('/api/nemotron/setup', {
701
  method: 'POST',
702
- headers: {'Content-Type': 'application/json'},
703
  body: JSON.stringify({path, dataset}),
704
  });
705
  clearInterval(progressInterval);
@@ -776,7 +773,7 @@ async function inferSpec() {
776
  try {
777
  const resp = await fetch(apiUrl('/api/infer-spec'), {
778
  method: 'POST',
779
- headers: {'Content-Type': 'application/json'},
780
  body: JSON.stringify({entity_text: text}),
781
  });
782
  const data = await resp.json();
@@ -820,7 +817,7 @@ async function runFullPipeline() {
820
  document.getElementById('pipelineProgressText').textContent = 'Setting up...';
821
  const sessResp = await fetch('/api/session', {
822
  method: 'POST',
823
- headers: {'Content-Type': 'application/json'},
824
  body: JSON.stringify({entity_text: text}),
825
  });
826
  const sessData = await sessResp.json();
@@ -834,7 +831,7 @@ async function runFullPipeline() {
834
 
835
  const segResp = await fetch(apiUrl('/api/suggest-segments'), {
836
  method: 'POST',
837
- headers: {'Content-Type': 'application/json'},
838
  body: JSON.stringify({
839
  entity_text: text,
840
  audience_context: audienceCtx || `People who would evaluate: ${text.substring(0, 200)}`,
@@ -859,7 +856,7 @@ async function runFullPipeline() {
859
  const desc = audienceCtx || `People evaluating: ${text.substring(0, 200)}`;
860
  const cohortResp = await fetch(apiUrl('/api/cohort/generate'), {
861
  method: 'POST',
862
- headers: {'Content-Type': 'application/json'},
863
  body: JSON.stringify({description: desc, audience_context: audienceCtx, segments, parallel: 3}),
864
  });
865
  const cohortData = await cohortResp.json();
@@ -867,7 +864,7 @@ async function runFullPipeline() {
867
  // Upload cohort to our session
868
  await fetch(`/api/cohort/upload/${sessionId}`, {
869
  method: 'POST',
870
- headers: {'Content-Type': 'application/json'},
871
  body: JSON.stringify(cohortData.cohort),
872
  });
873
 
@@ -894,7 +891,6 @@ async function runFullPipeline() {
894
 
895
  await new Promise((resolve, reject) => {
896
  const params = new URLSearchParams({parallel: 5, bias_calibration: biasCal});
897
- llmQueryParams().forEach((v, k) => params.set(k, v));
898
  const es = new EventSource(`/api/evaluate/stream/${sessionId}?${params}`);
899
 
900
  es.addEventListener('start', (e) => {
@@ -989,7 +985,7 @@ async function runDirections() {
989
  document.getElementById('cfProgressText').textContent = 'Proposing changes to test...';
990
  const suggestResp = await fetch(apiUrl('/api/suggest-changes'), {
991
  method: 'POST',
992
- headers: {'Content-Type': 'application/json'},
993
  body: JSON.stringify({entity_text: entityText, goal, concerns}),
994
  });
995
  const suggestData = await suggestResp.json();
@@ -1007,7 +1003,7 @@ async function runDirections() {
1007
  // POST config first, get a ticket, then SSE with just the ticket
1008
  const prepResp = await fetch(`/api/counterfactual/prepare/${sessionId}`, {
1009
  method: 'POST',
1010
- headers: {'Content-Type': 'application/json'},
1011
  body: JSON.stringify({
1012
  changes: suggestedChanges,
1013
  goal: goal,
@@ -1019,9 +1015,7 @@ async function runDirections() {
1019
  const {ticket} = await prepResp.json();
1020
 
1021
  await new Promise((resolve, reject) => {
1022
- const cfParams = new URLSearchParams({ticket});
1023
- llmQueryParams().forEach((v, k) => cfParams.set(k, v));
1024
- const es = new EventSource(`/api/counterfactual/stream/${sessionId}?${cfParams}`);
1025
 
1026
  es.addEventListener('start', (e) => {
1027
  const d = JSON.parse(e.data);
 
317
  <div class="step-num" style="border-color:var(--yellow);color:var(--yellow)">!</div>
318
  <div class="step-title">Connect your LLM</div>
319
  </div>
320
+ <p class="step-desc">SGO works with any OpenAI-compatible API. Your key stays in your browser and is sent to the server only via encrypted headers never logged, stored, or visible in URLs.</p>
321
  <div class="field">
322
  <label>API key</label>
323
  <input type="password" id="apiKeyInput" placeholder="sk-...">
 
600
  let llmModel = '';
601
 
602
  function llmHeaders() {
603
+ // Send credentials via Authorization header (never in URL/query params)
604
+ const h = {'Content-Type': 'application/json'};
605
+ if (llmApiKey) {
606
+ h['Authorization'] = `Bearer ${llmApiKey}|${llmBaseUrl}|${llmModel}`;
607
+ }
608
+ return h;
 
 
 
609
  }
610
 
611
  function apiUrl(path) {
612
+ // No credentials in URLs — they get logged
613
+ return path;
614
  }
615
 
616
  // XSS sanitization helper
 
696
  try {
697
  const resp = await fetch('/api/nemotron/setup', {
698
  method: 'POST',
699
+ headers: llmHeaders(),
700
  body: JSON.stringify({path, dataset}),
701
  });
702
  clearInterval(progressInterval);
 
773
  try {
774
  const resp = await fetch(apiUrl('/api/infer-spec'), {
775
  method: 'POST',
776
+ headers: llmHeaders(),
777
  body: JSON.stringify({entity_text: text}),
778
  });
779
  const data = await resp.json();
 
817
  document.getElementById('pipelineProgressText').textContent = 'Setting up...';
818
  const sessResp = await fetch('/api/session', {
819
  method: 'POST',
820
+ headers: llmHeaders(),
821
  body: JSON.stringify({entity_text: text}),
822
  });
823
  const sessData = await sessResp.json();
 
831
 
832
  const segResp = await fetch(apiUrl('/api/suggest-segments'), {
833
  method: 'POST',
834
+ headers: llmHeaders(),
835
  body: JSON.stringify({
836
  entity_text: text,
837
  audience_context: audienceCtx || `People who would evaluate: ${text.substring(0, 200)}`,
 
856
  const desc = audienceCtx || `People evaluating: ${text.substring(0, 200)}`;
857
  const cohortResp = await fetch(apiUrl('/api/cohort/generate'), {
858
  method: 'POST',
859
+ headers: llmHeaders(),
860
  body: JSON.stringify({description: desc, audience_context: audienceCtx, segments, parallel: 3}),
861
  });
862
  const cohortData = await cohortResp.json();
 
864
  // Upload cohort to our session
865
  await fetch(`/api/cohort/upload/${sessionId}`, {
866
  method: 'POST',
867
+ headers: llmHeaders(),
868
  body: JSON.stringify(cohortData.cohort),
869
  });
870
 
 
891
 
892
  await new Promise((resolve, reject) => {
893
  const params = new URLSearchParams({parallel: 5, bias_calibration: biasCal});
 
894
  const es = new EventSource(`/api/evaluate/stream/${sessionId}?${params}`);
895
 
896
  es.addEventListener('start', (e) => {
 
985
  document.getElementById('cfProgressText').textContent = 'Proposing changes to test...';
986
  const suggestResp = await fetch(apiUrl('/api/suggest-changes'), {
987
  method: 'POST',
988
+ headers: llmHeaders(),
989
  body: JSON.stringify({entity_text: entityText, goal, concerns}),
990
  });
991
  const suggestData = await suggestResp.json();
 
1003
  // POST config first, get a ticket, then SSE with just the ticket
1004
  const prepResp = await fetch(`/api/counterfactual/prepare/${sessionId}`, {
1005
  method: 'POST',
1006
+ headers: llmHeaders(),
1007
  body: JSON.stringify({
1008
  changes: suggestedChanges,
1009
  goal: goal,
 
1015
  const {ticket} = await prepResp.json();
1016
 
1017
  await new Promise((resolve, reject) => {
1018
+ const es = new EventSource(`/api/counterfactual/stream/${sessionId}?ticket=${ticket}`);
 
 
1019
 
1020
  es.addEventListener('start', (e) => {
1021
  const d = JSON.parse(e.data);