security: fix ENCRYPTION_KEY default, /verify-payment broken access control, path-traversal in design_name endpoints, /designs header-spoofing
Browse files- .env.example +2 -1
- server/api.py +21 -31
- server/auth.py +12 -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 |
-
|
|
|
|
| 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(
|
| 1125 |
-
"""List
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
| 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(
|