mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-10 23:00:44 +00:00
Harden agent policy and API import limits; fix docs links
This commit is contained in:
1 parent
ae4fa2498b
commit
0789dfcb55
6 files changed
+280
-60
No files matched your search
+55
-43
@@ -7,23 +7,18 @@ import tempfile
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import queue
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from typing import Any, List, Optional
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException, Request, Response
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
import asyncio
|
||||
import sys
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
import bcrypt
|
||||
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.util import get_remote_address
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
|
||||
from seedpass.core.manager import PasswordManager
|
||||
from seedpass.core.entry_types import EntryType
|
||||
from seedpass.core.api import UtilityService
|
||||
@@ -31,13 +26,26 @@ from seedpass.core.api import UtilityService
|
||||
_RATE_LIMIT = int(os.getenv("SEEDPASS_RATE_LIMIT", "100"))
|
||||
_RATE_WINDOW = int(os.getenv("SEEDPASS_RATE_WINDOW", "60"))
|
||||
_RATE_LIMIT_STR = f"{_RATE_LIMIT}/{_RATE_WINDOW} seconds"
|
||||
_MAX_IMPORT_BYTES = int(os.getenv("SEEDPASS_MAX_IMPORT_BYTES", str(10 * 1024 * 1024)))
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address, default_limits=[_RATE_LIMIT_STR])
|
||||
app = FastAPI()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def dynamic_cors_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
origins = {
|
||||
o.strip() for o in os.getenv("SEEDPASS_CORS_ORIGINS", "").split(",") if o.strip()
|
||||
}
|
||||
request_origin = request.headers.get("origin")
|
||||
if request_origin and request_origin in origins:
|
||||
response.headers["access-control-allow-origin"] = request_origin
|
||||
response.headers["vary"] = "Origin"
|
||||
return response
|
||||
|
||||
|
||||
def _get_pm(request: Request) -> PasswordManager:
|
||||
pm = getattr(request.app.state, "pm", None)
|
||||
assert pm is not None
|
||||
@@ -47,10 +55,31 @@ def _get_pm(request: Request) -> PasswordManager:
|
||||
def _check_token(request: Request, auth: str | None) -> None:
|
||||
if auth is None or not auth.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
token = auth.split(" ", 1)[1].encode()
|
||||
raw_token = auth.split(" ", 1)[1]
|
||||
token = raw_token.encode()
|
||||
token_hash = getattr(request.app.state, "token_hash", b"")
|
||||
if not token_hash or not bcrypt.checkpw(token, token_hash):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
_enforce_rate_limit(request, raw_token)
|
||||
|
||||
|
||||
def _enforce_rate_limit(request: Request, raw_token: str) -> None:
|
||||
now = time.monotonic()
|
||||
client = request.client.host if request.client else "unknown"
|
||||
key = f"{client}:{raw_token}"
|
||||
buckets = getattr(request.app.state, "rate_limit_buckets", None)
|
||||
if buckets is None:
|
||||
buckets = defaultdict(deque)
|
||||
request.app.state.rate_limit_buckets = buckets
|
||||
bucket = buckets[key]
|
||||
while bucket and (now - bucket[0]) > _RATE_WINDOW:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= _RATE_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Rate limit exceeded ({_RATE_LIMIT_STR})",
|
||||
)
|
||||
bucket.append(now)
|
||||
|
||||
|
||||
def _reload_relays(request: Request, relays: list[str]) -> None:
|
||||
@@ -82,22 +111,7 @@ def start_server(fingerprint: str | None = None) -> str:
|
||||
app.state.pm = pm
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
app.state.token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt())
|
||||
if not getattr(app.state, "limiter", None):
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
app.add_middleware(SlowAPIMiddleware)
|
||||
origins = [
|
||||
o.strip()
|
||||
for o in os.getenv("SEEDPASS_CORS_ORIGINS", "").split(",")
|
||||
if o.strip()
|
||||
]
|
||||
if origins and app.middleware_stack is None:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.state.rate_limit_buckets = defaultdict(deque)
|
||||
return raw_token
|
||||
|
||||
|
||||
@@ -126,7 +140,7 @@ async def search_entry(
|
||||
) -> List[Any]:
|
||||
_check_token(request, authorization)
|
||||
pm = _get_pm(request)
|
||||
results = await run_in_threadpool(pm.entry_manager.search_entries, query)
|
||||
results = pm.entry_manager.search_entries(query)
|
||||
return [
|
||||
{
|
||||
"id": idx,
|
||||
@@ -150,7 +164,7 @@ async def get_entry(
|
||||
_check_token(request, authorization)
|
||||
_require_password(request, password)
|
||||
pm = _get_pm(request)
|
||||
entry = await run_in_threadpool(pm.entry_manager.retrieve_entry, entry_id)
|
||||
entry = pm.entry_manager.retrieve_entry(entry_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return entry
|
||||
@@ -186,8 +200,7 @@ async def create_entry(
|
||||
]
|
||||
kwargs = {k: entry.get(k) for k in policy_keys if entry.get(k) is not None}
|
||||
|
||||
index = await run_in_threadpool(
|
||||
pm.entry_manager.add_entry,
|
||||
index = pm.entry_manager.add_entry(
|
||||
entry.get("label"),
|
||||
int(entry.get("length", 12)),
|
||||
entry.get("username"),
|
||||
@@ -197,10 +210,9 @@ async def create_entry(
|
||||
return {"id": index}
|
||||
|
||||
if etype == "totp":
|
||||
index = await run_in_threadpool(pm.entry_manager.get_next_index)
|
||||
index = pm.entry_manager.get_next_index()
|
||||
|
||||
uri = await run_in_threadpool(
|
||||
pm.entry_manager.add_totp,
|
||||
uri = pm.entry_manager.add_totp(
|
||||
entry.get("label"),
|
||||
pm.KEY_TOTP_DET if entry.get("deterministic", False) else None,
|
||||
secret=entry.get("secret"),
|
||||
@@ -214,8 +226,7 @@ async def create_entry(
|
||||
return {"id": index, "uri": uri}
|
||||
|
||||
if etype == "ssh":
|
||||
index = await run_in_threadpool(
|
||||
pm.entry_manager.add_ssh_key,
|
||||
index = pm.entry_manager.add_ssh_key(
|
||||
entry.get("label"),
|
||||
pm.parent_seed,
|
||||
index=entry.get("index"),
|
||||
@@ -225,8 +236,7 @@ async def create_entry(
|
||||
return {"id": index}
|
||||
|
||||
if etype == "pgp":
|
||||
index = await run_in_threadpool(
|
||||
pm.entry_manager.add_pgp_key,
|
||||
index = pm.entry_manager.add_pgp_key(
|
||||
entry.get("label"),
|
||||
pm.parent_seed,
|
||||
index=entry.get("index"),
|
||||
@@ -238,8 +248,7 @@ async def create_entry(
|
||||
return {"id": index}
|
||||
|
||||
if etype == "nostr":
|
||||
index = await run_in_threadpool(
|
||||
pm.entry_manager.add_nostr_key,
|
||||
index = pm.entry_manager.add_nostr_key(
|
||||
entry.get("label"),
|
||||
pm.parent_seed,
|
||||
index=entry.get("index"),
|
||||
@@ -249,8 +258,7 @@ async def create_entry(
|
||||
return {"id": index}
|
||||
|
||||
if etype == "key_value":
|
||||
index = await run_in_threadpool(
|
||||
pm.entry_manager.add_key_value,
|
||||
index = pm.entry_manager.add_key_value(
|
||||
entry.get("label"),
|
||||
entry.get("key"),
|
||||
entry.get("value"),
|
||||
@@ -264,8 +272,7 @@ async def create_entry(
|
||||
if etype == "seed"
|
||||
else pm.entry_manager.add_managed_account
|
||||
)
|
||||
index = await run_in_threadpool(
|
||||
func,
|
||||
index = func(
|
||||
entry.get("label"),
|
||||
pm.parent_seed,
|
||||
index=entry.get("index"),
|
||||
@@ -638,7 +645,12 @@ async def import_vault(
|
||||
|
||||
if file is None:
|
||||
raise HTTPException(status_code=400, detail="Missing file")
|
||||
data = await file.read()
|
||||
data = await file.read(_MAX_IMPORT_BYTES + 1)
|
||||
if len(data) > _MAX_IMPORT_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"Uploaded file exceeds max size of {_MAX_IMPORT_BYTES} bytes",
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||
tmp.write(data)
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
Reference in new issue
Block a user