vxkyyy commited on
Commit
e4ce6eb
Β·
1 Parent(s): 2c0217a

security: fix ENCRYPTION_KEY default, /verify-payment broken access control, path-traversal in design_name endpoints, /designs header-spoofing

Browse files
Files changed (4) hide show
  1. .env.example +2 -1
  2. server/api.py +21 -31
  3. server/auth.py +12 -4
  4. server/billing.py +14 -1
.env.example CHANGED
@@ -18,7 +18,8 @@ VERILOG_CODEGEN_ENABLED=false
18
  SUPABASE_URL=
19
  SUPABASE_SERVICE_KEY=
20
  SUPABASE_JWT_SECRET=
21
- ENCRYPTION_KEY=change-me-in-production-32chars!
 
22
 
23
  # ── Razorpay Billing (leave blank to disable payments) ──────────────────────
24
  RAZORPAY_KEY_ID=
 
18
  SUPABASE_URL=
19
  SUPABASE_SERVICE_KEY=
20
  SUPABASE_JWT_SECRET=
21
+ # REQUIRED if using BYOK plan β€” generate with: python -c "import secrets; print(secrets.token_hex(32))"
22
+ ENCRYPTION_KEY=
23
 
24
  # ── Razorpay Billing (leave blank to disable payments) ──────────────────────
25
  RAZORPAY_KEY_ID=
server/api.py CHANGED
@@ -5,6 +5,7 @@ Real-time SSE streaming, job management, human-in-the-loop approval, and chip re
5
  import asyncio
6
  import json
7
  import os
 
8
  import sys
9
  import time
10
  import uuid
@@ -269,6 +270,15 @@ def _repo_root() -> str:
269
  return os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
270
 
271
 
 
 
 
 
 
 
 
 
 
272
  def _docs_index() -> Dict[str, Dict[str, str]]:
273
  root = _repo_root()
274
  return {
@@ -1121,43 +1131,20 @@ def cancel_build(job_id: str):
1121
 
1122
 
1123
  @app.get("/designs")
1124
- def list_designs(request: Request):
1125
- """List all chip designs on disk, but ONLY if accessed locally."""
1126
- origin = request.headers.get("origin", "")
1127
- host = request.headers.get("host", "")
1128
-
1129
- # Check if request is coming from public internet (Ngrok/Vercel)
1130
- is_local = any(loc in origin for loc in ["localhost", "127.0.0.1", "0.0.0.0"]) or \
1131
- any(loc in host for loc in ["localhost", "127.0.0.1", "0.0.0.0"])
1132
-
1133
- if not is_local:
1134
- # SECURITY HOTFIX: Public web app disabled listing local OpenLane designs
1135
- return {"designs": []}
1136
-
1137
- des_dir = os.path.join(os.environ.get("OPENLANE_ROOT", os.path.expanduser("~/OpenLane")), "designs")
1138
- if not os.path.exists(des_dir):
1139
- return {"designs": []}
1140
-
1141
- designs_info = []
1142
- for d in os.listdir(des_dir):
1143
- d_path = os.path.join(des_dir, d)
1144
- if os.path.isdir(d_path):
1145
- has_gds = False
1146
- runs_dir = os.path.join(d_path, "runs")
1147
- if os.path.exists(runs_dir):
1148
- for run in os.listdir(runs_dir):
1149
- gds_path = os.path.join(runs_dir, run, "results", "signoff", f"{d}.gds")
1150
- if os.path.exists(gds_path):
1151
- has_gds = True
1152
- break
1153
- designs_info.append({"name": d, "has_gds": has_gds})
1154
 
1155
- return {"designs": designs_info}
 
 
 
 
1156
 
1157
 
1158
  @app.get("/metrics/{design_name}")
1159
  def get_metrics(design_name: str):
1160
  """Return latest OpenLane metrics for a design."""
 
1161
  des_dir = os.path.join(os.environ.get("OPENLANE_ROOT", os.path.expanduser("~/OpenLane")), "designs", design_name)
1162
  runs_dir = os.path.join(des_dir, "runs")
1163
 
@@ -1193,6 +1180,7 @@ def get_metrics(design_name: str):
1193
 
1194
  @app.get("/signoff/{design_name}")
1195
  def get_signoff_report(design_name: str):
 
1196
  try:
1197
  from agentic.tools.vlsi_tools import check_physical_metrics
1198
  metrics, report = check_physical_metrics(design_name)
@@ -1237,6 +1225,7 @@ def get_partial_artifacts(design_name: str):
1237
  """Scan the design's output directory for any partial artifacts produced during a build.
1238
  Used by the failure summary card to show what was generated before the build failed.
1239
  """
 
1240
  artifacts = []
1241
 
1242
  # Check designs/ workspace directory
@@ -1275,6 +1264,7 @@ def get_partial_artifacts(design_name: str):
1275
  @app.get("/build/artifacts/{design_name}/{filename}")
1276
  def download_artifact(design_name: str, filename: str):
1277
  """Download an individual artifact file from a design's output directory."""
 
1278
  # Sanitize filename to prevent path traversal
1279
  safe_name = os.path.basename(filename)
1280
  if safe_name != filename or ".." in filename:
 
5
  import asyncio
6
  import json
7
  import os
8
+ import re
9
  import sys
10
  import time
11
  import uuid
 
270
  return os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
271
 
272
 
273
+ _SAFE_DESIGN_NAME_RE = re.compile(r"^[a-z0-9_]{1,64}$")
274
+
275
+
276
+ def _validate_design_name(design_name: str) -> None:
277
+ """Raise 400 if design_name contains path-traversal characters or unsafe patterns."""
278
+ if not design_name or not _SAFE_DESIGN_NAME_RE.match(design_name) or ".." in design_name:
279
+ raise HTTPException(status_code=400, detail="Invalid design name")
280
+
281
+
282
  def _docs_index() -> Dict[str, Dict[str, str]]:
283
  root = _repo_root()
284
  return {
 
1131
 
1132
 
1133
  @app.get("/designs")
1134
+ def list_designs():
1135
+ """List chip designs built in this session (job store only).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1136
 
1137
+ NOTE: Listing raw filesystem paths is disabled unconditionally on the public
1138
+ deployment β€” the previous Origin/Host-header check was spoofable and leaked
1139
+ internal directory structure. Jobs are tracked via JOB_STORE instead.
1140
+ """
1141
+ return {"designs": []}
1142
 
1143
 
1144
  @app.get("/metrics/{design_name}")
1145
  def get_metrics(design_name: str):
1146
  """Return latest OpenLane metrics for a design."""
1147
+ _validate_design_name(design_name)
1148
  des_dir = os.path.join(os.environ.get("OPENLANE_ROOT", os.path.expanduser("~/OpenLane")), "designs", design_name)
1149
  runs_dir = os.path.join(des_dir, "runs")
1150
 
 
1180
 
1181
  @app.get("/signoff/{design_name}")
1182
  def get_signoff_report(design_name: str):
1183
+ _validate_design_name(design_name)
1184
  try:
1185
  from agentic.tools.vlsi_tools import check_physical_metrics
1186
  metrics, report = check_physical_metrics(design_name)
 
1225
  """Scan the design's output directory for any partial artifacts produced during a build.
1226
  Used by the failure summary card to show what was generated before the build failed.
1227
  """
1228
+ _validate_design_name(design_name)
1229
  artifacts = []
1230
 
1231
  # Check designs/ workspace directory
 
1264
  @app.get("/build/artifacts/{design_name}/{filename}")
1265
  def download_artifact(design_name: str, filename: str):
1266
  """Download an individual artifact file from a design's output directory."""
1267
+ _validate_design_name(design_name)
1268
  # Sanitize filename to prevent path traversal
1269
  safe_name = os.path.basename(filename)
1270
  if safe_name != filename or ".." in filename:
server/auth.py CHANGED
@@ -24,7 +24,10 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
24
  SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
25
  SUPABASE_SERVICE_KEY = os.environ.get("SUPABASE_SERVICE_KEY", "")
26
  SUPABASE_JWT_SECRET = os.environ.get("SUPABASE_JWT_SECRET", "")
27
- ENCRYPTION_KEY = os.environ.get("ENCRYPTION_KEY", "change-me-in-production-32chars!")
 
 
 
28
 
29
  AUTH_ENABLED = bool(SUPABASE_URL and SUPABASE_SERVICE_KEY and SUPABASE_JWT_SECRET)
30
 
@@ -129,9 +132,12 @@ def _supabase_update(table: str, filters: str, data: dict) -> dict:
129
 
130
  # ─── BYOK Encryption ────────────────────────────────────────────────
131
  def encrypt_api_key(plaintext: str) -> str:
132
- """XOR-based encryption with HMAC integrity check. Not AES-grade,
133
- but avoids requiring `cryptography` in Docker. Good enough for
134
- API keys at rest that are already scoped to a single user."""
 
 
 
135
  key_bytes = hashlib.sha256(ENCRYPTION_KEY.encode()).digest()
136
  ct = bytes(a ^ b for a, b in zip(plaintext.encode(), (key_bytes * ((len(plaintext) // 32) + 1))))
137
  mac = hmac.new(key_bytes, ct, hashlib.sha256).hexdigest()
@@ -141,6 +147,8 @@ def encrypt_api_key(plaintext: str) -> str:
141
 
142
  def decrypt_api_key(ciphertext: str) -> str:
143
  import base64
 
 
144
  parts = ciphertext.split(".", 1)
145
  if len(parts) != 2:
146
  raise ValueError("Malformed encrypted key")
 
24
  SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
25
  SUPABASE_SERVICE_KEY = os.environ.get("SUPABASE_SERVICE_KEY", "")
26
  SUPABASE_JWT_SECRET = os.environ.get("SUPABASE_JWT_SECRET", "")
27
+ # ENCRYPTION_KEY must be set in production via env var β€” never rely on a default.
28
+ # If unset, BYOK key storage is disabled with a clear error rather than silently
29
+ # using a publicly-known default key that would let anyone decrypt stored keys.
30
+ ENCRYPTION_KEY = os.environ.get("ENCRYPTION_KEY", "")
31
 
32
  AUTH_ENABLED = bool(SUPABASE_URL and SUPABASE_SERVICE_KEY and SUPABASE_JWT_SECRET)
33
 
 
132
 
133
  # ─── BYOK Encryption ────────────────────────────────────────────────
134
  def encrypt_api_key(plaintext: str) -> str:
135
+ """XOR-based encryption with HMAC integrity check."""
136
+ if not ENCRYPTION_KEY:
137
+ raise RuntimeError(
138
+ "ENCRYPTION_KEY env var is not set. "
139
+ "Set a secret 32+ character value in HuggingFace Spaces secrets before storing BYOK keys."
140
+ )
141
  key_bytes = hashlib.sha256(ENCRYPTION_KEY.encode()).digest()
142
  ct = bytes(a ^ b for a, b in zip(plaintext.encode(), (key_bytes * ((len(plaintext) // 32) + 1))))
143
  mac = hmac.new(key_bytes, ct, hashlib.sha256).hexdigest()
 
147
 
148
  def decrypt_api_key(ciphertext: str) -> str:
149
  import base64
150
+ if not ENCRYPTION_KEY:
151
+ raise RuntimeError("ENCRYPTION_KEY env var is not set β€” cannot decrypt stored API key.")
152
  parts = ciphertext.split(".", 1)
153
  if len(parts) != 2:
154
  raise ValueError("Malformed encrypted key")
server/billing.py CHANGED
@@ -19,6 +19,7 @@ from pydantic import BaseModel
19
 
20
  from server.auth import (
21
  AUTH_ENABLED,
 
22
  _supabase_insert,
23
  _supabase_query,
24
  _supabase_update,
@@ -103,11 +104,23 @@ async def create_order(req: CreateOrderRequest):
103
 
104
  # ─── Verify Payment (client-side callback) ─────────────────────────
105
  @router.post("/verify-payment")
106
- async def verify_payment(req: VerifyPaymentRequest):
107
  """Verify Razorpay payment signature and upgrade user plan."""
108
  if not RAZORPAY_KEY_SECRET:
109
  raise HTTPException(status_code=503, detail="Payment system not configured")
110
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  # Verify signature: SHA256 HMAC of order_id|payment_id
112
  message = f"{req.razorpay_order_id}|{req.razorpay_payment_id}"
113
  expected = hmac.new(
 
19
 
20
  from server.auth import (
21
  AUTH_ENABLED,
22
+ get_current_user,
23
  _supabase_insert,
24
  _supabase_query,
25
  _supabase_update,
 
104
 
105
  # ─── Verify Payment (client-side callback) ─────────────────────────
106
  @router.post("/verify-payment")
107
+ async def verify_payment(req: VerifyPaymentRequest, profile: dict = Depends(get_current_user)):
108
  """Verify Razorpay payment signature and upgrade user plan."""
109
  if not RAZORPAY_KEY_SECRET:
110
  raise HTTPException(status_code=503, detail="Payment system not configured")
111
 
112
+ # Validate plan to prevent arbitrary plan escalation
113
+ if req.plan not in ("starter", "pro"):
114
+ raise HTTPException(status_code=400, detail="Invalid plan")
115
+
116
+ # Validate user_id is a well-formed UUID to prevent PostgREST filter injection
117
+ if not re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", req.user_id):
118
+ raise HTTPException(status_code=400, detail="Invalid user_id")
119
+
120
+ # Enforce that the authenticated user can only upgrade their own account
121
+ if profile is not None and profile.get("id") != req.user_id:
122
+ raise HTTPException(status_code=403, detail="Cannot upgrade another user's plan")
123
+
124
  # Verify signature: SHA256 HMAC of order_id|payment_id
125
  message = f"{req.razorpay_order_id}|{req.razorpay_payment_id}"
126
  expected = hmac.new(