agnes-the-ai-analyst/docs/archive/superpowers/plans/2026-04-09-security-fixes.md
ZdenekSrotyr a48524509a
docs: consolidate and de-clutter the documentation tree (#306)
CLAUDE.md rewritten (708 -> ~320 lines): four overlapping release
sections collapsed to one, stale v1->v35 schema history dropped (it
lives in CHANGELOG), marketplace endpoint internals and verbose
process sections moved out or tightened.

New focused docs:
- docs/RELEASING.md - release process, deploy workflows, CI quirks
  (RELEASE_TEMPLATE.md folded in as an appendix)
- docs/marketplace.md - marketplace ingestion + re-serving internals
- docs/README.md - documentation index by audience, linked from
  README.md and CLAUDE.md

Archived under docs/archive/: docs/superpowers/ (52 historical
planning artifacts), HACKATHON.md, pd-ps-comments.md,
security-audit-2026-04.md, future/NOTIFICATIONS.md.

Removed the docs/auto-install.md stub. Fixed dangling links in
connectors/jira/README.md and dev_docs/README.md, repointed
code/doc references to archived paths.
2026-05-14 18:54:22 +00:00

623 lines
20 KiB
Markdown

# Security Fixes for Production Deployment
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix all critical and important security findings before deploying to paying customers.
**Architecture:** Targeted fixes to auth, RBAC, query endpoint, script sandbox, and upload endpoints. No architectural changes — just closing specific holes identified by security review.
**Tech Stack:** Python 3.13, FastAPI, DuckDB, argon2-cffi, PyJWT
**Source:** Security posture review 2026-04-09 (findings C1-C3, I1-I5, I10-I11)
---
## File Map
| File | Responsibility | Tasks |
|------|---------------|-------|
| `app/auth/router.py` | Token endpoint auth | 1 |
| `app/web/router.py` | Web UI route guards | 2 |
| `app/api/query.py` | SQL query blocklist | 3 |
| `app/api/scripts.py` | Script execution RBAC | 4 |
| `app/api/catalog.py` | Catalog profile access control | 5 |
| `app/api/upload.py` | Upload path leak fix | 6 |
| `app/auth/providers/google.py` | Cookie secure flag | 7 |
| `app/instance_config.py` | Instance name YAML path fix | 8 |
---
### Task 1: Block /auth/token for OAuth-only users (C1)
Users without `password_hash` (OAuth-only) can get a JWT by just sending their email. This is an account takeover vulnerability.
**Files:**
- Modify: `app/auth/router.py:47-56`
- Test: `tests/test_auth_providers.py`
- [ ] **Step 1: Write the failing test**
```python
# In tests/test_auth_providers.py, add to TestTokenEndpoint:
def test_token_rejected_for_oauth_only_user(self, client, e2e_env):
"""OAuth-only users (no password_hash) cannot get token via /auth/token."""
from src.db import get_system_db
from src.repositories.users import UserRepository
conn = get_system_db()
repo = UserRepository(conn)
# Create user without password (simulates Google OAuth user)
repo.create(id="oauth-user", email="oauth@test.com", role="analyst")
conn.close()
resp = client.post("/auth/token", json={"email": "oauth@test.com"})
assert resp.status_code == 401
assert "password" in resp.json()["detail"].lower() or "provider" in resp.json()["detail"].lower()
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_auth_providers.py::TestTokenEndpoint::test_token_rejected_for_oauth_only_user -v`
Expected: FAIL — currently returns 200
- [ ] **Step 3: Fix the auth logic**
In `app/auth/router.py`, replace lines 47-56 with:
```python
# Require authentication proof
if user.get("password_hash"):
# User has password — require and verify it
if not request.password:
raise HTTPException(status_code=401, detail="Password required")
try:
from argon2 import PasswordHasher
ph = PasswordHasher()
ph.verify(user["password_hash"], request.password)
except Exception:
raise HTTPException(status_code=401, detail="Invalid password")
else:
# No password set — user must use their auth provider (Google, magic link)
raise HTTPException(
status_code=401,
detail="This account uses external authentication. Please log in via your configured provider.",
)
```
Also update the docstring on line 41:
```python
"""Issue a JWT token. Requires password for password-protected accounts.
OAuth-only accounts must use their auth provider instead."""
```
- [ ] **Step 4: Run test to verify it passes**
Run: `pytest tests/test_auth_providers.py -v`
Expected: All pass
- [ ] **Step 5: Verify bootstrap still works**
Run: `pytest tests/test_bootstrap.py -v`
Expected: All pass (bootstrap creates user WITH password or returns token directly)
- [ ] **Step 6: Commit**
```bash
git add app/auth/router.py tests/test_auth_providers.py
git commit -m "fix: block /auth/token for OAuth-only users — require password or external provider"
```
---
### Task 2: Add role checks to web admin pages (C2)
`/admin/tables`, `/admin/permissions`, `/corporate-memory/admin` are accessible to any authenticated user.
**Files:**
- Modify: `app/web/router.py:431-452,388-394`
- Test: `tests/test_web_ui.py`
- [ ] **Step 1: Write the failing tests**
```python
# In tests/test_web_ui.py, add:
@pytest.fixture
def analyst_cookie(web_client, tmp_path, monkeypatch):
"""Create analyst user (non-admin) and return cookie."""
from src.db import get_system_db
from src.repositories.users import UserRepository
conn = get_system_db()
UserRepository(conn).create(id="analyst1", email="analyst@test.com", name="Analyst", role="analyst")
conn.close()
resp = web_client.post("/auth/token", json={"email": "analyst@test.com"})
# analyst has no password_hash — need to give them one first
# Actually: after Task 1, this will fail. Create with password instead:
from argon2 import PasswordHasher
from src.db import get_system_db as gsdb
c = gsdb()
c.execute("UPDATE users SET password_hash = ? WHERE id = ?",
[PasswordHasher().hash("testpass"), "analyst1"])
c.close()
resp = web_client.post("/auth/token", json={"email": "analyst@test.com", "password": "testpass"})
assert resp.status_code == 200
return {"access_token": resp.json()["access_token"]}
class TestWebUIRBAC:
def test_admin_tables_requires_admin(self, web_client, analyst_cookie):
resp = web_client.get("/admin/tables", cookies=analyst_cookie)
assert resp.status_code == 403
def test_admin_permissions_requires_admin(self, web_client, analyst_cookie):
resp = web_client.get("/admin/permissions", cookies=analyst_cookie)
assert resp.status_code == 403
def test_corporate_memory_admin_requires_km_admin(self, web_client, analyst_cookie):
resp = web_client.get("/corporate-memory/admin", cookies=analyst_cookie)
assert resp.status_code == 403
def test_admin_can_access_admin_tables(self, web_client, admin_cookie):
resp = web_client.get("/admin/tables", cookies=admin_cookie)
assert resp.status_code == 200
def test_admin_can_access_admin_permissions(self, web_client, admin_cookie):
resp = web_client.get("/admin/permissions", cookies=admin_cookie)
assert resp.status_code == 200
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pytest tests/test_web_ui.py::TestWebUIRBAC -v`
Expected: analyst can access admin pages (403 expected, gets 200)
- [ ] **Step 3: Add role checks to web routes**
In `app/web/router.py`, add import at top:
```python
from src.rbac import Role
from app.auth.dependencies import require_role
```
Replace line 434 (`user: dict = Depends(get_current_user)`) in `admin_tables`:
```python
user: dict = Depends(require_role(Role.ADMIN)),
```
Replace line 448 in `admin_permissions_page`:
```python
user: dict = Depends(require_role(Role.ADMIN)),
```
Replace line 391 in `corporate_memory_admin`:
```python
user: dict = Depends(require_role(Role.KM_ADMIN)),
```
- [ ] **Step 4: Run tests**
Run: `pytest tests/test_web_ui.py -v`
Expected: All pass
- [ ] **Step 5: Commit**
```bash
git add app/web/router.py tests/test_web_ui.py
git commit -m "fix: require admin/km_admin role for web admin pages"
```
---
### Task 3: Expand SQL query blocklist with DuckDB metadata (C3)
The query endpoint doesn't block `information_schema`, `duckdb_tables()`, `duckdb_columns()`, relative paths, or `pragma_` functions.
**Files:**
- Modify: `app/api/query.py:40-54`
- Test: `tests/test_security.py`
- [ ] **Step 1: Write the failing tests**
```python
# In tests/test_security.py, add to TestQuerySecurity:
def test_blocks_information_schema(self, client, auth_headers):
resp = client.post("/api/query", json={"sql": "SELECT * FROM information_schema.tables"}, headers=auth_headers)
assert resp.status_code == 400
def test_blocks_duckdb_tables(self, client, auth_headers):
resp = client.post("/api/query", json={"sql": "SELECT * FROM duckdb_tables()"}, headers=auth_headers)
assert resp.status_code == 400
def test_blocks_duckdb_columns(self, client, auth_headers):
resp = client.post("/api/query", json={"sql": "SELECT * FROM duckdb_columns()"}, headers=auth_headers)
assert resp.status_code == 400
def test_blocks_duckdb_databases(self, client, auth_headers):
resp = client.post("/api/query", json={"sql": "SELECT * FROM duckdb_databases()"}, headers=auth_headers)
assert resp.status_code == 400
def test_blocks_relative_path(self, client, auth_headers):
resp = client.post("/api/query", json={"sql": "SELECT * FROM '../secret.parquet'"}, headers=auth_headers)
assert resp.status_code == 400
def test_blocks_pragma_table_info(self, client, auth_headers):
resp = client.post("/api/query", json={"sql": "SELECT * FROM pragma_table_info('users')"}, headers=auth_headers)
assert resp.status_code == 400
```
- [ ] **Step 2: Run to verify failures**
Run: `pytest tests/test_security.py::TestQuerySecurity -v`
Expected: New tests FAIL
- [ ] **Step 3: Expand the blocklist**
In `app/api/query.py`, replace the `blocked` list (lines 40-54):
```python
blocked = [
# DDL/DML
"drop ", "delete ", "insert ", "update ", "alter ", "create ",
"copy ", "attach ", "detach ", "load ", "install ",
"export ", "import ", "pragma ", "call ",
# File access functions
"read_csv", "read_json", "read_parquet", "read_text",
"write_csv", "write_parquet", "read_blob", "read_ndjson",
"parquet_scan", "parquet_metadata", "parquet_schema",
"json_scan", "csv_scan",
"query_table", "iceberg_scan", "delta_scan",
"glob(", "list_files",
# URL/path schemes
"'/", '"/','http://', 'https://', 's3://', 'gcs://',
"'../", '"../',
# DuckDB metadata (leaks schema info regardless of RBAC)
"information_schema", "duckdb_tables", "duckdb_columns",
"duckdb_databases", "duckdb_settings", "duckdb_functions",
"duckdb_views", "duckdb_indexes", "duckdb_schemas",
"pragma_table_info", "pragma_storage_info",
# Multiple statements
";",
]
```
- [ ] **Step 4: Run tests**
Run: `pytest tests/test_security.py::TestQuerySecurity -v`
Expected: All pass
- [ ] **Step 5: Commit**
```bash
git add app/api/query.py tests/test_security.py
git commit -m "fix: block DuckDB metadata functions and relative paths in query endpoint"
```
---
### Task 4: Restrict script execution to analyst role (I2/I4)
Any authenticated user (including viewers) can deploy and execute scripts.
**Files:**
- Modify: `app/api/scripts.py:53-56,72-73,83-84`
- Test: `tests/test_security.py`
- [ ] **Step 1: Write the failing test**
```python
# In tests/test_security.py, add:
class TestScriptRBAC:
def test_viewer_cannot_run_scripts(self, client):
"""Viewers should not be able to execute scripts."""
from src.db import get_system_db
from src.repositories.users import UserRepository
from app.auth.jwt import create_access_token
conn = get_system_db()
UserRepository(conn).create(id="viewer1", email="viewer@test.com", role="viewer")
conn.close()
token = create_access_token(user_id="viewer1", email="viewer@test.com", role="viewer")
headers = {"Authorization": f"Bearer {token}"}
resp = client.post("/api/scripts/run", json={
"name": "test", "source": "print('hi')"
}, headers=headers)
assert resp.status_code == 403
def test_viewer_cannot_deploy_scripts(self, client):
from src.db import get_system_db
from src.repositories.users import UserRepository
from app.auth.jwt import create_access_token
conn = get_system_db()
try:
UserRepository(conn).create(id="viewer2", email="viewer2@test.com", role="viewer")
except Exception:
pass
conn.close()
token = create_access_token(user_id="viewer2", email="viewer2@test.com", role="viewer")
headers = {"Authorization": f"Bearer {token}"}
resp = client.post("/api/scripts/deploy", json={
"name": "test", "source": "print('hi')", "schedule": ""
}, headers=headers)
assert resp.status_code == 403
```
- [ ] **Step 2: Run to verify failures**
Run: `pytest tests/test_security.py::TestScriptRBAC -v`
Expected: FAIL — viewers get 200
- [ ] **Step 3: Add role requirements**
In `app/api/scripts.py`, add import:
```python
from app.auth.dependencies import require_role
from src.rbac import Role
```
Replace `get_current_user` with `require_role(Role.ANALYST)` on these endpoints:
- `deploy_script` (line 56): `user: dict = Depends(require_role(Role.ANALYST)),`
- `run_ad_hoc` (line 73): `user: dict = Depends(require_role(Role.ANALYST)),`
- `run_deployed` (line 84): `user: dict = Depends(require_role(Role.ANALYST)),`
- `list_scripts` (line 46): keep as `get_current_user` (read-only, safe for all)
- `undeploy_script` (line 101): `user: dict = Depends(require_role(Role.ADMIN)),`
- [ ] **Step 4: Run tests**
Run: `pytest tests/test_security.py tests/test_api_scripts.py -v`
Expected: All pass
- [ ] **Step 5: Commit**
```bash
git add app/api/scripts.py tests/test_security.py
git commit -m "fix: restrict script deploy/execute to analyst role, undeploy to admin"
```
---
### Task 5: Add access control to catalog profile endpoints (I5)
`/api/catalog/profile/{table_name}` returns profile data without checking table access.
**Files:**
- Modify: `app/api/catalog.py:18-39`
- Test: `tests/test_access_control.py`
- [ ] **Step 1: Write the failing test**
```python
# In tests/test_access_control.py, add to TestPrivateTablesRestricted or new class:
class TestCatalogProfileAccessControl:
def test_profile_denied_for_private_table(self, client, e2e_env):
"""Analyst without explicit access should not see profile of private table."""
# Assumes 'private_table' is registered as private in e2e_env
# and the test user doesn't have access
from app.auth.jwt import create_access_token
token = create_access_token(user_id="analyst-no-access", email="noaccess@test.com", role="analyst")
headers = {"Authorization": f"Bearer {token}"}
resp = client.get("/api/catalog/profile/private_table", headers=headers)
assert resp.status_code == 403
```
- [ ] **Step 2: Run to verify failure**
- [ ] **Step 3: Add access check**
In `app/api/catalog.py`, add import:
```python
from src.rbac import can_access_table
```
In `get_table_profile` (line 18), after `user: dict = Depends(get_current_user)`, add:
```python
# Check table-level access
if not can_access_table(user, table_name, conn):
raise HTTPException(status_code=403, detail=f"Access denied to table '{table_name}'")
```
Add the same check to the `/profile/{table_name}/refresh` endpoint if it exists.
- [ ] **Step 4: Run tests**
Run: `pytest tests/test_access_control.py -v`
Expected: All pass
- [ ] **Step 5: Commit**
```bash
git add app/api/catalog.py tests/test_access_control.py
git commit -m "fix: add per-table access control to catalog profile endpoints"
```
---
### Task 6: Stop leaking internal file paths in upload responses (I10)
Upload endpoints return `"path": str(target)` exposing server filesystem structure.
**Files:**
- Modify: `app/api/upload.py:37,59`
- Test: `tests/test_api_complete.py`
- [ ] **Step 1: Write the failing test**
```python
# In tests/test_api_complete.py, add to TestUpload:
def test_upload_does_not_leak_absolute_path(self, client, admin_headers):
"""Upload response should not contain absolute filesystem paths."""
import io
resp = client.post(
"/api/upload/session/test-session",
files={"file": ("test.txt", io.BytesIO(b"hello"), "text/plain")},
headers=admin_headers,
)
assert resp.status_code == 200
data = resp.json()
assert not data.get("path", "").startswith("/"), "Response should not leak absolute path"
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_api_complete.py::TestUpload::test_upload_does_not_leak_absolute_path -v`
Expected: FAIL — returns `/data/user_sessions/...`
- [ ] **Step 3: Fix upload responses**
In `app/api/upload.py`, replace `"path": str(target)` with `"filename": filename` in both endpoints:
Line 37:
```python
return {"status": "ok", "filename": filename, "size": len(content)}
```
Line 59:
```python
return {"status": "ok", "filename": filename, "size": len(content)}
```
- [ ] **Step 4: Run tests**
Run: `pytest tests/test_api_complete.py -v`
Expected: All pass
- [ ] **Step 5: Commit**
```bash
git add app/api/upload.py tests/test_api_complete.py
git commit -m "fix: return filename instead of absolute path in upload responses"
```
---
### Task 7: Force secure cookie flag in production (I11)
Google OAuth callback sets `secure=True` only when the request is HTTPS. Behind a TLS-terminating proxy, the app sees HTTP.
**Files:**
- Modify: `app/auth/providers/google.py:92-98`
- [ ] **Step 1: Fix the cookie setting**
In `app/auth/providers/google.py`, replace lines 92-98:
```python
is_production = os.environ.get("TESTING", "").lower() not in ("1", "true")
response = RedirectResponse(url="/dashboard", status_code=302)
response.set_cookie(
key="access_token", value=jwt_token,
httponly=True, max_age=86400, samesite="lax",
secure=is_production, # Always secure in production (behind TLS proxy)
)
```
Note: `max_age` reduced from `86400 * 30` (30 days) to `86400` (1 day) to match JWT expiry.
- [ ] **Step 2: Run auth tests**
Run: `pytest tests/test_auth_providers.py -v`
Expected: All pass
- [ ] **Step 3: Commit**
```bash
git add app/auth/providers/google.py
git commit -m "fix: force secure cookie flag in production, align cookie max_age with JWT expiry"
```
---
### Task 8: Fix instance_config YAML path for instance name (C4)
`get_instance_name()` reads flat key `instance_name` but YAML structure is `instance.name`.
**Files:**
- Modify: `app/instance_config.py:48-53`
- Test: `tests/test_instance_config.py`
- [ ] **Step 1: Write the failing test**
```python
# In tests/test_instance_config.py, add:
def test_reads_nested_instance_name(self, tmp_path, monkeypatch):
"""get_instance_name should read instance.name from YAML, not flat instance_name."""
monkeypatch.setenv("DATA_DIR", str(tmp_path))
monkeypatch.setenv("TESTING", "1")
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key-min-32-characters!!")
config_dir = tmp_path / "config"
config_dir.mkdir(exist_ok=True)
(config_dir / "instance.yaml").write_text(
"instance:\n name: Acme Analytics\n subtitle: Data Team\n"
)
import importlib
import app.instance_config as mod
importlib.reload(mod)
assert mod.get_instance_name() == "Acme Analytics"
assert mod.get_instance_subtitle() == "Data Team"
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_instance_config.py::TestInstanceConfig::test_reads_nested_instance_name -v`
Expected: FAIL — returns "AI Data Analyst" instead of "Acme Analytics"
- [ ] **Step 3: Fix the accessor functions**
In `app/instance_config.py`, replace lines 48-53:
```python
def get_instance_name() -> str:
return get_value("instance", "name", default="AI Data Analyst")
def get_instance_subtitle() -> str:
return get_value("instance", "subtitle", default="")
```
- [ ] **Step 4: Run tests**
Run: `pytest tests/test_instance_config.py -v`
Expected: All pass
- [ ] **Step 5: Commit**
```bash
git add app/instance_config.py tests/test_instance_config.py
git commit -m "fix: get_instance_name reads nested instance.name from YAML"
```
---
## Execution Order
Tasks are independent and can run in parallel (different files). Recommended order by impact:
1. **Task 1** (C1) — account takeover via /auth/token
2. **Task 2** (C2) — admin pages exposed
3. **Task 3** (C3) — SQL metadata leaks
4. **Task 4** (I4) — script execution RBAC
5. **Task 5** (I5) — catalog profile access control
6. **Task 8** (C4) — instance name config
7. **Task 6** (I10) — upload path leak
8. **Task 7** (I11) — cookie secure flag
**Verification after all tasks:**
```bash
pytest tests/ -v --tb=short # All 650+ tests pass
```