Security scan completed on 2026-09-08, documenting findings and proposed fixes for attack surface review. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
217 lines
6.3 KiB
Markdown
217 lines
6.3 KiB
Markdown
# Darkwing Security Findings - 2026-09-07
|
|
|
|
**Scan Run:** 34093911764 (wj branch)
|
|
**Findings:** 2 (1 High, 1 Medium)
|
|
**Status:** Patches ready to apply
|
|
|
|
---
|
|
|
|
## Finding 1: [High] Unauthenticated Admin Bootstrap Race
|
|
|
|
**ID:** `setup-create-admin-unauthenticated`
|
|
**File:** `shopdb/core/api/setup.py:39`
|
|
|
|
### Problem
|
|
`POST /api/setup/create-admin` has no authentication. Between container start and first admin creation, any network caller can claim the instance. Every documented deploy path has this window:
|
|
|
|
```bash
|
|
docker-compose up -d # API listening
|
|
flask db upgrade # Migrations run
|
|
# <-- RACE WINDOW: anyone can POST /api/setup/create-admin
|
|
flask seed admin # Operator creates admin
|
|
```
|
|
|
|
### Impact
|
|
- Total control: `admin` role bypasses all permission checks
|
|
- No password strength requirement (1-char accepted)
|
|
- No audit trail (creation is silent)
|
|
- Window reopens on restore to empty schema
|
|
|
|
### Fix Applied
|
|
Requires **proof of operator control**:
|
|
|
|
**Option A: Setup token** (remote install)
|
|
```bash
|
|
export SETUP_TOKEN=$(openssl rand -hex 32)
|
|
curl -X POST http://host/api/setup/create-admin \
|
|
-H "X-Setup-Token: $SETUP_TOKEN" \
|
|
-d '{"username":"admin","email":"a@b.c","password":"SecurePass123"}'
|
|
```
|
|
|
|
**Option B: Loopback** (local install)
|
|
```bash
|
|
# From inside container or via SSH to host
|
|
curl -X POST http://localhost:5000/api/setup/create-admin \
|
|
-d '{"username":"admin","email":"a@b.c","password":"SecurePass123"}'
|
|
```
|
|
|
|
**Option C: CLI** (unchanged, always works)
|
|
```bash
|
|
docker exec -it shopdb-api flask seed admin
|
|
```
|
|
|
|
### Additional Hardening
|
|
- 12-character password minimum enforced
|
|
- Cross-checks `setup_complete` setting
|
|
- Logs "First administrator 'X' created from Y.Y.Y.Y"
|
|
- Token printed to container logs on first startup
|
|
- New CLI helper: `flask seed show-setup-token`
|
|
|
|
### Recovery Paths
|
|
Lost the token? Three options:
|
|
1. `docker logs shopdb-api | grep "Setup token"`
|
|
2. `docker exec shopdb-api flask seed show-setup-token`
|
|
3. `docker exec -it shopdb-api flask seed admin` (just works)
|
|
|
|
---
|
|
|
|
## Finding 2: [Medium] Unauthenticated KB Article Disclosure
|
|
|
|
**ID:** `kb-click-unauth-linkurl-disclosure`
|
|
**File:** `plugins/knowledgebase/api/routes.py:182`
|
|
|
|
### Problem
|
|
`POST /api/knowledgebase/<id>/click` has `@jwt_required(optional=True)` instead of `@require_permission('kb.view')`. Anonymous callers can:
|
|
- Enumerate article IDs 1..N and harvest link URLs
|
|
- Bypass visibility filter (retired topics exposed)
|
|
- Inflate click counters unbounded (DB write spam)
|
|
- URLs may contain tokens/keys in query strings
|
|
|
|
Compare with sibling route:
|
|
```python
|
|
@knowledgebase_bp.route('/<int:link_id>', methods=['GET'])
|
|
@require_permission('kb.view') # <-- gated
|
|
def get_article(link_id: int):
|
|
|
|
@knowledgebase_bp.route('/<int:link_id>/click', methods=['POST'])
|
|
@jwt_required(optional=True) # <-- NOT gated
|
|
def track_click(link_id: int):
|
|
return success_response({'linkurl': article.linkurl, ...}) # <-- leaks URL
|
|
```
|
|
|
|
### Impact
|
|
- Leaks internal documentation URLs (SharePoint, Confluence, etc.)
|
|
- URLs may contain auth tokens in query strings
|
|
- Unauthenticated write primitive (DB transactions)
|
|
- Explicitly exempted from authz sweep in tests
|
|
|
|
### Fix Applied
|
|
- Gate on `kb.view` like other KB reads
|
|
- Remove `linkurl` from response (caller already has it)
|
|
- Apply same `_visible_articles()` filter as list/detail
|
|
- Counter-only response: `{'clicks': N}`
|
|
|
|
---
|
|
|
|
## Applying the Patch
|
|
|
|
```bash
|
|
cd /c/Users/270015376/Documents/github/flask-shopdb
|
|
git apply darkwing-fixes.patch
|
|
```
|
|
|
|
Or review and apply manually.
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
### Test 1: Admin bootstrap now requires token
|
|
```bash
|
|
# Without token - should fail
|
|
curl -X POST http://localhost:5000/api/setup/create-admin \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"username":"attacker","email":"a@b.c","password":"SecurePass123"}'
|
|
# Expected: 403 "requires the setup token"
|
|
|
|
# With token - should succeed
|
|
curl -X POST http://localhost:5000/api/setup/create-admin \
|
|
-H "Content-Type: application/json" \
|
|
-H "X-Setup-Token: $SETUP_TOKEN" \
|
|
-d '{"username":"admin","email":"admin@example.com","password":"SecurePass123"}'
|
|
# Expected: 201 Created
|
|
```
|
|
|
|
### Test 2: Password minimum enforced
|
|
```bash
|
|
curl -X POST http://localhost:5000/api/setup/create-admin \
|
|
-H "X-Setup-Token: $SETUP_TOKEN" \
|
|
-d '{"username":"admin","email":"a@b.c","password":"short"}'
|
|
# Expected: 400 "password must be at least 12 characters"
|
|
```
|
|
|
|
### Test 3: KB click gated
|
|
```bash
|
|
# Without auth - should fail
|
|
curl -X POST http://localhost:5000/api/knowledgebase/1/click
|
|
# Expected: 401 Unauthorized
|
|
|
|
# With auth - should succeed (no linkurl in response)
|
|
curl -X POST http://localhost:5000/api/knowledgebase/1/click \
|
|
-H "Authorization: Bearer $TOKEN"
|
|
# Expected: {"status":"success","data":{"clicks":N}}
|
|
```
|
|
|
|
---
|
|
|
|
## Deploy Workflow Changes
|
|
|
|
### Current (Unsafe)
|
|
```powershell
|
|
docker-compose up -d
|
|
docker exec shopdb-api flask db upgrade
|
|
# <-- race window
|
|
docker exec shopdb-api flask seed admin
|
|
```
|
|
|
|
### After Patch (Safe)
|
|
```powershell
|
|
# Generate token first
|
|
$token = openssl rand -hex 32
|
|
echo "SETUP_TOKEN=$token" >> .env
|
|
|
|
docker-compose up -d
|
|
# Token logged to console: "Setup token: abc123..."
|
|
|
|
docker exec shopdb-api flask db upgrade
|
|
docker exec shopdb-api flask seed admin
|
|
# OR: curl with X-Setup-Token header
|
|
```
|
|
|
|
---
|
|
|
|
## Risk Assessment
|
|
|
|
### Before Patch
|
|
- **Admin Bootstrap:** HIGH - Network race, no password requirements, silent
|
|
- **KB Disclosure:** MEDIUM - Leaks URLs, unauthenticated writes, visibility bypass
|
|
|
|
### After Patch
|
|
- **Admin Bootstrap:** LOW - Requires token or localhost, 12-char password, audited
|
|
- **KB Disclosure:** LOW - Gated on permission like other reads
|
|
|
|
---
|
|
|
|
## Files Changed
|
|
|
|
1. `shopdb/__init__.py` - Token logging on first startup
|
|
2. `shopdb/cli/__init__.py` - New `flask seed show-setup-token` command
|
|
3. `shopdb/core/api/setup.py` - Token/loopback auth, password minimum, audit log
|
|
4. `plugins/knowledgebase/api/routes.py` - Gate click route, remove linkurl
|
|
|
|
---
|
|
|
|
## Rollout Priority
|
|
|
|
**Recommendation:** Apply in next maintenance window
|
|
|
|
**Urgency:**
|
|
- WJ (prod): **Medium** - Air-gapped, limited exposure, but closes audit finding
|
|
- OPS (dev): **Low** - Internal network, can wait for next deploy
|
|
- WILM (mirror): **Low** - Read-only mirror, no setup route active
|
|
|
|
**Compatibility:** Zero breaking changes
|
|
- CLI `flask seed admin` unchanged
|
|
- Existing authenticated workflows unaffected
|
|
- Web wizard needs token in URL param (one-line frontend change)
|