chuckfinca Claude Opus 4.6 (1M context) commited on
Commit
5bb517a
·
1 Parent(s): 79d1384

Security hardening: server-side sessions, timing-safe tokens

Browse files

- Server-side session store for workspace/scratch paths — clients
get an opaque session_id, never see filesystem paths
- Server-side cost tracking — client can't bypass limits
- Timing-safe token comparison via hmac.compare_digest
- Path validation ensures workspace is in expected temp directory
- Sanitize error messages (no raw exceptions to client)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files changed (1) hide show
  1. app.py +71 -42
app.py CHANGED
@@ -6,8 +6,10 @@ streaming question/answer, file upload, and trace endpoints.
6
 
7
  from __future__ import annotations
8
 
 
9
  import json
10
  import os
 
11
  import tempfile
12
  import time
13
  from collections.abc import Generator
@@ -50,6 +52,41 @@ BASE_PROMPT = (
50
  hf_api = HfApi(token=HF_TOKEN) if HF_TOKEN else None
51
 
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  # ---------------------------------------------------------------------------
54
  # Helpers
55
  # ---------------------------------------------------------------------------
@@ -152,13 +189,11 @@ def format_stats_from_trace(trace: dict) -> str:
152
 
153
  def stream_question(
154
  question: str,
155
- workspace_path: str,
156
- scratch_path: str,
157
- session_cost: float,
158
  token: str,
159
  ) -> Generator[str, None, None]:
160
  """Streaming API — yields JSON event strings."""
161
- if ACCESS_TOKEN and token != ACCESS_TOKEN:
162
  yield json.dumps({"type": "error", "error": "Invalid access token."})
163
  return
164
 
@@ -166,21 +201,20 @@ def stream_question(
166
  yield json.dumps({"type": "error", "error": "LH_MODEL not set."})
167
  return
168
 
169
- if session_cost >= MAX_SESSION_COST:
 
 
 
 
 
170
  yield json.dumps({
171
  "type": "error",
172
- "error": f"Session cost limit reached (${session_cost:.2f} / ${MAX_SESSION_COST:.2f}).",
173
  })
174
  return
175
 
176
- workspace = Path(workspace_path) if workspace_path else None
177
- if not workspace:
178
- yield json.dumps({"type": "error", "error": "No documents uploaded."})
179
- return
180
-
181
- if not scratch_path:
182
- scratch_path = tempfile.mkdtemp(prefix="lh-scratch-")
183
- scratch_dir = Path(scratch_path)
184
 
185
  system_prompt = build_system_prompt(base_prompt=BASE_PROMPT, workspace=workspace)
186
  messages: list[Message] = [
@@ -209,12 +243,16 @@ def stream_question(
209
  tool_call_count += 1
210
  yield json.dumps({"type": "tool_call", "count": tool_call_count, "name": event.name})
211
  except Exception as exc:
212
- yield json.dumps({"type": "error", "error": str(exc)})
 
213
  return
214
 
215
  trace = agent_run.trace
216
  trace.wall_time_s = round(time.monotonic() - start, 2)
217
 
 
 
 
218
  clean_answer, sources = process_citations(trace.answer or "", workspace)
219
 
220
  result = {
@@ -234,9 +272,7 @@ def stream_question(
234
  "sources": sources,
235
  "stats": format_stats(trace),
236
  "trace_html": trace_html,
237
- "workspace_path": str(workspace),
238
- "scratch_path": str(scratch_dir),
239
- "session_cost": session_cost + (trace.cost or 0),
240
  })
241
 
242
 
@@ -250,68 +286,61 @@ def build_app() -> gr.Blocks:
250
  gr.Markdown("# Document Explorer API\n\nThis Space provides the API backend. "
251
  "Visit [appsimple.io/explore](https://appsimple.io/explore) for the full interface.")
252
 
253
- # Streaming ask endpoint
254
  ask_inputs = [
255
  gr.Textbox(visible=False), # question
256
- gr.Textbox(visible=False), # workspace_path
257
- gr.Textbox(visible=False), # scratch_path
258
- gr.Number(visible=False), # session_cost
259
  gr.Textbox(visible=False), # token
260
  ]
261
  ask_output = gr.Textbox(visible=False)
262
 
263
- def api_ask_stream(question, workspace_path, scratch_path, session_cost, token):
264
- for event_json in stream_question(
265
- question, workspace_path, scratch_path, session_cost, token
266
- ):
267
  yield event_json
268
 
269
  ask_btn = gr.Button(visible=False)
270
  ask_btn.click(api_ask_stream, inputs=ask_inputs, outputs=ask_output, api_name="ask")
271
 
272
- # Upload endpoint — accepts token + JSON list of file paths from /gradio_api/upload
273
  upload_input = gr.Textbox(visible=False)
274
  upload_output = gr.Textbox(visible=False)
275
 
276
  def api_upload(payload):
277
- print(f"[upload] raw payload: {payload!r}")
278
  try:
279
  data = json.loads(payload)
280
  token = data.get("token", "")
281
  file_paths = data.get("paths", [])
282
- except (json.JSONDecodeError, AttributeError) as exc:
283
- print(f"[upload] parse error: {exc}")
284
  return json.dumps({"error": "Invalid payload."})
285
- print(f"[upload] token={'***' if token else 'empty'}, paths={file_paths}")
286
- if ACCESS_TOKEN and token != ACCESS_TOKEN:
287
  return json.dumps({"error": "Invalid access token."})
288
  if not file_paths:
289
  return json.dumps({"error": "No files provided."})
290
  workspace = save_uploaded_files(file_paths)
291
- print(f"[upload] workspace created: {workspace}")
292
- return json.dumps({"workspace_path": str(workspace), "file_count": len(file_paths)})
293
 
294
  upload_btn = gr.Button(visible=False)
295
  upload_btn.click(api_upload, inputs=upload_input, outputs=upload_output, api_name="upload")
296
 
297
- # Document viewer endpoint
298
  doc_input = gr.Textbox(visible=False)
299
- doc_ws_input = gr.Textbox(visible=False)
300
  doc_output = gr.Textbox(visible=False)
301
 
302
- def api_get_doc(filename, workspace_path):
303
- if not workspace_path or not filename:
 
304
  return json.dumps({"error": "not found"})
305
  safe_name = Path(filename).name
306
- if not safe_name.endswith(".md"):
307
- safe_name += ".md"
308
- filepath = Path(workspace_path) / safe_name
309
  if not filepath.is_file():
310
  return json.dumps({"error": "not found"})
311
  return json.dumps({"filename": safe_name, "content": filepath.read_text()})
312
 
313
  doc_btn = gr.Button(visible=False)
314
- doc_btn.click(api_get_doc, inputs=[doc_input, doc_ws_input], outputs=doc_output, api_name="doc")
315
 
316
  # Trace list endpoint
317
  traces_input = gr.Textbox(visible=False)
 
6
 
7
  from __future__ import annotations
8
 
9
+ import hmac
10
  import json
11
  import os
12
+ import secrets
13
  import tempfile
14
  import time
15
  from collections.abc import Generator
 
52
  hf_api = HfApi(token=HF_TOKEN) if HF_TOKEN else None
53
 
54
 
55
+ # ---------------------------------------------------------------------------
56
+ # Server-side session store
57
+ # ---------------------------------------------------------------------------
58
+
59
+ _sessions: dict[str, dict] = {}
60
+ _TEMP_PREFIX = "/tmp/lh-"
61
+
62
+
63
+ def _create_session(workspace_path: str) -> str:
64
+ session_id = secrets.token_urlsafe(16)
65
+ scratch_path = tempfile.mkdtemp(prefix="lh-scratch-")
66
+ _sessions[session_id] = {
67
+ "workspace": workspace_path,
68
+ "scratch": scratch_path,
69
+ "cost": 0.0,
70
+ }
71
+ return session_id
72
+
73
+
74
+ def _get_session(session_id: str) -> dict | None:
75
+ session = _sessions.get(session_id)
76
+ if not session:
77
+ return None
78
+ # Validate paths are in expected temp directories
79
+ if not session["workspace"].startswith(_TEMP_PREFIX):
80
+ return None
81
+ return session
82
+
83
+
84
+ def _validate_token(token: str) -> bool:
85
+ if not ACCESS_TOKEN:
86
+ return True
87
+ return hmac.compare_digest(token, ACCESS_TOKEN)
88
+
89
+
90
  # ---------------------------------------------------------------------------
91
  # Helpers
92
  # ---------------------------------------------------------------------------
 
189
 
190
  def stream_question(
191
  question: str,
192
+ session_id: str,
 
 
193
  token: str,
194
  ) -> Generator[str, None, None]:
195
  """Streaming API — yields JSON event strings."""
196
+ if not _validate_token(token):
197
  yield json.dumps({"type": "error", "error": "Invalid access token."})
198
  return
199
 
 
201
  yield json.dumps({"type": "error", "error": "LH_MODEL not set."})
202
  return
203
 
204
+ session = _get_session(session_id)
205
+ if not session:
206
+ yield json.dumps({"type": "error", "error": "Invalid session. Please re-upload your documents."})
207
+ return
208
+
209
+ if session["cost"] >= MAX_SESSION_COST:
210
  yield json.dumps({
211
  "type": "error",
212
+ "error": f"Session cost limit reached (${session['cost']:.2f} / ${MAX_SESSION_COST:.2f}).",
213
  })
214
  return
215
 
216
+ workspace = Path(session["workspace"])
217
+ scratch_dir = Path(session["scratch"])
 
 
 
 
 
 
218
 
219
  system_prompt = build_system_prompt(base_prompt=BASE_PROMPT, workspace=workspace)
220
  messages: list[Message] = [
 
243
  tool_call_count += 1
244
  yield json.dumps({"type": "tool_call", "count": tool_call_count, "name": event.name})
245
  except Exception as exc:
246
+ yield json.dumps({"type": "error", "error": "An error occurred during processing."})
247
+ print(f"ERROR in stream_question: {exc}")
248
  return
249
 
250
  trace = agent_run.trace
251
  trace.wall_time_s = round(time.monotonic() - start, 2)
252
 
253
+ # Update server-side session cost
254
+ session["cost"] += trace.cost or 0
255
+
256
  clean_answer, sources = process_citations(trace.answer or "", workspace)
257
 
258
  result = {
 
272
  "sources": sources,
273
  "stats": format_stats(trace),
274
  "trace_html": trace_html,
275
+ "session_cost": session["cost"],
 
 
276
  })
277
 
278
 
 
286
  gr.Markdown("# Document Explorer API\n\nThis Space provides the API backend. "
287
  "Visit [appsimple.io/explore](https://appsimple.io/explore) for the full interface.")
288
 
289
+ # Streaming ask endpoint — takes question, session_id, token
290
  ask_inputs = [
291
  gr.Textbox(visible=False), # question
292
+ gr.Textbox(visible=False), # session_id
 
 
293
  gr.Textbox(visible=False), # token
294
  ]
295
  ask_output = gr.Textbox(visible=False)
296
 
297
+ def api_ask_stream(question, session_id, token):
298
+ for event_json in stream_question(question, session_id, token):
 
 
299
  yield event_json
300
 
301
  ask_btn = gr.Button(visible=False)
302
  ask_btn.click(api_ask_stream, inputs=ask_inputs, outputs=ask_output, api_name="ask")
303
 
304
+ # Upload endpoint — accepts files, creates workspace and session
305
  upload_input = gr.Textbox(visible=False)
306
  upload_output = gr.Textbox(visible=False)
307
 
308
  def api_upload(payload):
 
309
  try:
310
  data = json.loads(payload)
311
  token = data.get("token", "")
312
  file_paths = data.get("paths", [])
313
+ except (json.JSONDecodeError, AttributeError):
 
314
  return json.dumps({"error": "Invalid payload."})
315
+ if not _validate_token(token):
 
316
  return json.dumps({"error": "Invalid access token."})
317
  if not file_paths:
318
  return json.dumps({"error": "No files provided."})
319
  workspace = save_uploaded_files(file_paths)
320
+ session_id = _create_session(str(workspace))
321
+ return json.dumps({"session_id": session_id, "file_count": len(file_paths)})
322
 
323
  upload_btn = gr.Button(visible=False)
324
  upload_btn.click(api_upload, inputs=upload_input, outputs=upload_output, api_name="upload")
325
 
326
+ # Document viewer endpoint — uses session_id for path lookup
327
  doc_input = gr.Textbox(visible=False)
328
+ doc_session_input = gr.Textbox(visible=False)
329
  doc_output = gr.Textbox(visible=False)
330
 
331
+ def api_get_doc(filename, session_id):
332
+ session = _get_session(session_id)
333
+ if not session or not filename:
334
  return json.dumps({"error": "not found"})
335
  safe_name = Path(filename).name
336
+ workspace = Path(session["workspace"])
337
+ filepath = workspace / safe_name
 
338
  if not filepath.is_file():
339
  return json.dumps({"error": "not found"})
340
  return json.dumps({"filename": safe_name, "content": filepath.read_text()})
341
 
342
  doc_btn = gr.Button(visible=False)
343
+ doc_btn.click(api_get_doc, inputs=[doc_input, doc_session_input], outputs=doc_output, api_name="doc")
344
 
345
  # Trace list endpoint
346
  traces_input = gr.Textbox(visible=False)