Harden agent policy and API import limits; fix docs links

This commit is contained in:
thePR0M3TH3AN committed 2026-02-23 16:24:53 -05:00
1 parent ae4fa2498b
commit 0789dfcb55
6 files changed
+280 -60

No files matched your search

+2 -4
View File
@@ -224,7 +224,6 @@ Follow these steps to set up SeedPass on your local machine.
python -m pip install --require-hashes -r requirements.lock
python -m pip install -e .
```
// 🔧 merged conflicting changes from codex/locate-command-usage-issue-in-seedpass vs beta
After reinstalling, run `which seedpass` on Linux/macOS or `where seedpass` on Windows to confirm the command resolves to your virtual environment's `seedpass` executable.
#### Linux Clipboard Support
@@ -322,7 +321,7 @@ seedpass util generate-password --length 20 --special-mode safe --exclude-ambigu
# on an external drive.
```
For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md). Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md).
For additional command examples, see [docs/docs/content/01-getting-started/01-advanced_cli.md](docs/docs/content/01-getting-started/01-advanced_cli.md). Details on the REST API can be found in [docs/docs/content/01-getting-started/02-api_reference.md](docs/docs/content/01-getting-started/02-api_reference.md).
### Getting Started with the GUI
@@ -436,14 +435,13 @@ If this command displays `usage: main.py` instead of the Typer help output, an o
```bash
python -m pip install -e .
```
// 🔧 merged conflicting changes from codex/locate-command-usage-issue-in-seedpass vs beta
You can confirm which executable will run with:
```bash
which seedpass # or 'where seedpass' on Windows
```
For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). The REST API is described in [docs/api_reference.md](docs/api_reference.md).
For a full list of commands see [docs/docs/content/01-getting-started/01-advanced_cli.md](docs/docs/content/01-getting-started/01-advanced_cli.md). The REST API is described in [docs/docs/content/01-getting-started/02-api_reference.md](docs/docs/content/01-getting-started/02-api_reference.md).
### Agent Mode (Non-Interactive)
+3 -3
View File
@@ -245,8 +245,8 @@ seedpass list --filter totp
# on an external drive.
```
For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md).
Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md).
For additional command examples, see [01-getting-started/01-advanced_cli.md](01-getting-started/01-advanced_cli.md).
Details on the REST API can be found in [01-getting-started/02-api_reference.md](01-getting-started/02-api_reference.md).
### Vault JSON Layout
@@ -285,7 +285,7 @@ You can explore other CLI commands using:
```bash
seedpass --help
```
For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). The REST API is described in [docs/api_reference.md](docs/api_reference.md).
For a full list of commands see [01-getting-started/01-advanced_cli.md](01-getting-started/01-advanced_cli.md). The REST API is described in [01-getting-started/02-api_reference.md](01-getting-started/02-api_reference.md).
### Running the Application
+55 -43
View File
@@ -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)
+51 -8
View File
@@ -47,19 +47,45 @@ def _policy_path() -> Path:
return APP_DIR / "agent_policy.json"
def _load_policy() -> dict:
def _deny_all_policy() -> dict:
return {
"allow_kinds": [],
"deny_private_reveal": list(ALL_ENTRY_TYPES),
"allow_export_import": False,
}
def _load_policy(*, strict: bool = False) -> dict:
path = _policy_path()
if not path.exists():
return dict(DEFAULT_POLICY)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return dict(DEFAULT_POLICY)
except Exception as exc:
if strict:
raise ValueError("agent policy file is not valid JSON") from exc
return _deny_all_policy()
if not isinstance(data, dict):
if strict:
raise ValueError("agent policy file must be a JSON object")
return _deny_all_policy()
policy = dict(DEFAULT_POLICY)
if isinstance(data.get("allow_kinds"), list):
policy["allow_kinds"] = [str(v) for v in data["allow_kinds"]]
allow_kinds = [str(v) for v in data["allow_kinds"]]
invalid = [v for v in allow_kinds if v not in ALL_ENTRY_TYPES]
if invalid and strict:
raise ValueError(f"agent policy has invalid allow_kinds entries: {invalid}")
policy["allow_kinds"] = [v for v in allow_kinds if v in ALL_ENTRY_TYPES]
if isinstance(data.get("deny_private_reveal"), list):
policy["deny_private_reveal"] = [str(v) for v in data["deny_private_reveal"]]
deny_private = [str(v) for v in data["deny_private_reveal"]]
invalid = [v for v in deny_private if v not in ALL_ENTRY_TYPES]
if invalid and strict:
raise ValueError(
f"agent policy has invalid deny_private_reveal entries: {invalid}"
)
policy["deny_private_reveal"] = [v for v in deny_private if v in ALL_ENTRY_TYPES]
if isinstance(data.get("allow_export_import"), bool):
policy["allow_export_import"] = data["allow_export_import"]
return policy
@@ -187,7 +213,11 @@ def agent_init(
@app.command("policy-show")
def agent_policy_show() -> None:
"""Show the active agent policy."""
typer.echo(json.dumps(_load_policy(), indent=2))
try:
policy = _load_policy(strict=True)
except ValueError as exc:
raise typer.BadParameter(str(exc))
typer.echo(json.dumps(policy, indent=2))
@app.command("policy-set")
@@ -211,7 +241,7 @@ def agent_policy_set(
),
) -> None:
"""Set policy flags used by agent commands."""
policy = _load_policy()
policy = _load_policy(strict=False)
if allow_kind:
policy["allow_kinds"] = list(allow_kind)
if deny_private_kind:
@@ -240,7 +270,20 @@ def agent_get(
if not fingerprint:
raise typer.BadParameter("Specify target profile with --fingerprint.")
password = _password_from_env(password_env)
policy = _load_policy()
try:
policy = _load_policy(strict=True)
except ValueError as exc:
typer.echo(
json.dumps(
{
"status": "denied",
"reason": "invalid_policy",
"detail": str(exc),
},
indent=2,
)
)
raise typer.Exit(1)
pm = PasswordManager(fingerprint=fingerprint, password=password)
service = EntryService(pm)
+26
View File
@@ -284,6 +284,32 @@ async def test_vault_import_via_upload(client, tmp_path):
assert called.get("sync") is True
@pytest.mark.anyio
async def test_vault_import_upload_too_large(client):
cl, token = client
called = {}
api.app.state.pm.handle_import_database = lambda path: called.setdefault(
"import_called", True
)
api.app.state.pm.sync_vault = lambda: called.setdefault("sync_called", True)
old_limit = api._MAX_IMPORT_BYTES
api._MAX_IMPORT_BYTES = 8
try:
headers = {"Authorization": f"Bearer {token}"}
res = await cl.post(
"/api/v1/vault/import",
files={"file": ("big.json.enc", b"123456789")},
headers=headers,
)
finally:
api._MAX_IMPORT_BYTES = old_limit
assert res.status_code == 413
assert "exceeds max size" in res.json()["detail"]
assert called == {}
@pytest.mark.anyio
async def test_vault_import_invalid_extension(client):
cl, token = client
+143 -2
View File
@@ -67,6 +67,23 @@ def test_agent_init_creates_profile(monkeypatch, tmp_path):
assert (profile_dir / "parent_seed.enc").exists()
def test_agent_init_requires_password_env(monkeypatch, tmp_path):
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
monkeypatch.delenv("SEEDPASS_PASSWORD", raising=False)
result = runner.invoke(
app,
[
"agent",
"init",
"--seed",
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
],
)
assert result.exit_code == 2
combined = f"{result.stdout}{result.stderr}"
assert "Missing password env var" in combined
def test_agent_get_requires_fingerprint(monkeypatch):
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
result = runner.invoke(app, ["agent", "get", "example"])
@@ -100,7 +117,7 @@ def test_agent_get_password_json(monkeypatch):
monkeypatch.setattr(
agent_cli,
"_load_policy",
lambda: {"allow_kinds": ["password"], "deny_private_reveal": []},
lambda **kwargs: {"allow_kinds": ["password"], "deny_private_reveal": []},
)
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"])
@@ -136,7 +153,7 @@ def test_agent_get_denied_by_policy(monkeypatch):
monkeypatch.setattr(
agent_cli,
"_load_policy",
lambda: {
lambda **kwargs: {
"allow_kinds": ["password", "totp", "key_value"],
"deny_private_reveal": ["seed"],
},
@@ -147,3 +164,127 @@ def test_agent_get_denied_by_policy(monkeypatch):
payload = json.loads(result.stdout)
assert payload["status"] == "denied"
assert payload["reason"] in {"kind_not_allowed", "private_kind_blocked"}
def test_agent_get_ambiguous_returns_error_payload(monkeypatch):
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
monkeypatch.setattr(
agent_cli,
"PasswordManager",
lambda fingerprint, password: SimpleNamespace(
fingerprint=fingerprint, password=password
),
)
class DummyEntryService:
def __init__(self, _pm):
pass
def search_entries(self, _q):
return [
(2, "Example1", "", "", False, EntryType.PASSWORD),
(3, "Example2", "", "", False, EntryType.PASSWORD),
]
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
monkeypatch.setattr(
agent_cli,
"_load_policy",
lambda **kwargs: {"allow_kinds": ["password"], "deny_private_reveal": []},
)
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"])
assert result.exit_code == 1
payload = json.loads(result.stdout)
assert payload["status"] == "error"
assert payload["reason"] == "ambiguous_or_missing"
assert payload["match_count"] == 2
def test_agent_get_key_value_json(monkeypatch):
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
monkeypatch.setattr(
agent_cli,
"PasswordManager",
lambda fingerprint, password: SimpleNamespace(
fingerprint=fingerprint, password=password
),
)
class DummyEntryService:
def __init__(self, _pm):
pass
def search_entries(self, _q):
return [(2, "Key", "", "", False, EntryType.KEY_VALUE)]
def retrieve_entry(self, _i):
return {"kind": EntryType.KEY_VALUE.value, "label": "Key", "value": "v123"}
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
monkeypatch.setattr(
agent_cli,
"_load_policy",
lambda **kwargs: {"allow_kinds": ["key_value"], "deny_private_reveal": []},
)
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "key"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["status"] == "ok"
assert payload["kind"] == "key_value"
assert payload["secret"] == "v123"
def test_agent_get_unsupported_kind_payload(monkeypatch):
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
monkeypatch.setattr(
agent_cli,
"PasswordManager",
lambda fingerprint, password: SimpleNamespace(
fingerprint=fingerprint, password=password
),
)
class DummyEntryService:
def __init__(self, _pm):
pass
def search_entries(self, _q):
return [(4, "SSH", "", "", False, EntryType.SSH)]
def retrieve_entry(self, _i):
return {"kind": EntryType.SSH.value, "label": "SSH"}
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
monkeypatch.setattr(
agent_cli,
"_load_policy",
lambda **kwargs: {"allow_kinds": ["ssh"], "deny_private_reveal": []},
)
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "ssh"])
assert result.exit_code == 1
payload = json.loads(result.stdout)
assert payload["status"] == "error"
assert payload["reason"] == "unsupported_kind_for_agent_get"
def test_agent_policy_show_invalid_json(monkeypatch, tmp_path):
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
(tmp_path / "agent_policy.json").write_text("{", encoding="utf-8")
result = runner.invoke(app, ["agent", "policy-show"])
assert result.exit_code == 2
combined = f"{result.stdout}{result.stderr}"
assert "not valid JSON" in combined
def test_agent_get_denied_when_policy_invalid(monkeypatch, tmp_path):
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
(tmp_path / "agent_policy.json").write_text("{", encoding="utf-8")
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"])
assert result.exit_code == 1
payload = json.loads(result.stdout)
assert payload["status"] == "denied"
assert payload["reason"] == "invalid_policy"