security(TASK-8): fix 8 pentest findings — C1/C3/H1/M1/M2/L2

CRITICAL:
- C1: /lnd-connect-info now requires session auth, CORS wildcard removed
- C3: DEV_MODE removed from production service file (dev override only)

HIGH:
- H1: node-message endpoint now verifies ed25519 signatures when
  provided, logs warning for unsigned messages

MEDIUM:
- M1: content.add rejects filenames containing ".." (path traversal)
- M2: NIP-07 postMessage responses use specific origin instead of '*'

LOW:
- L2: Onion validation now enforces strict v3 format (56 base32 chars
  + ".onion", exactly 62 chars, no colons)

Previously fixed: C2 (RPC creds generated per-install from secrets)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-18 19:45:10 +00:00
parent 302f22019d
commit 0d28d28bf7
7 changed files with 44 additions and 16 deletions

View File

@ -277,27 +277,45 @@ impl ApiHandler {
struct Incoming {
from_pubkey: Option<String>,
message: Option<String>,
signature: Option<String>,
}
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
from_pubkey: None,
message: None,
signature: None,
});
if let (Some(from), Some(msg)) = (incoming.from_pubkey, incoming.message) {
if let (Some(from), Some(msg)) = (incoming.from_pubkey.as_ref(), incoming.message.as_ref()) {
// Validate from_pubkey is a valid hex ed25519 pubkey
if !is_valid_pubkey_hex(&from) {
if !is_valid_pubkey_hex(from) {
return Ok(Response::builder()
.status(StatusCode::BAD_REQUEST)
.header("Content-Type", "application/json")
.body(hyper::Body::from(r#"{"error":"Invalid pubkey format"}"#))
.unwrap());
}
// Verify ed25519 signature if provided (required for trusted messages)
if let Some(sig_hex) = &incoming.signature {
match crate::identity::NodeIdentity::verify(from, msg.as_bytes(), sig_hex) {
Ok(true) => {}
_ => {
return Ok(Response::builder()
.status(StatusCode::FORBIDDEN)
.header("Content-Type", "application/json")
.body(hyper::Body::from(r#"{"error":"Invalid signature"}"#))
.unwrap());
}
}
} else {
// No signature — accept but mark as unverified
tracing::warn!("Node message from {} has no signature — unverified", &from[..16.min(from.len())]);
}
// Sanitize log output to prevent log injection
let safe_from = sanitize_log_string(&from);
let safe_msg = sanitize_log_string(&msg);
let safe_from = sanitize_log_string(from);
let safe_msg = sanitize_log_string(msg);
tracing::info!("Received message from {}: {}", safe_from, safe_msg);
// Sanitize stored message content (strip HTML entities)
let clean_from = sanitize_html(&from);
let clean_msg = sanitize_html(&msg);
let clean_from = sanitize_html(from);
let clean_msg = sanitize_html(msg);
node_msg::store_received(&clean_from, &clean_msg).await;
}
Ok(Response::builder()

View File

@ -25,6 +25,10 @@ impl RpcHandler {
.get("filename")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing filename"))?;
// Prevent path traversal
if filename.contains("..") || filename.contains('\0') {
anyhow::bail!("Invalid filename: path traversal not allowed");
}
let mime_type = params
.get("mime_type")
.and_then(|v| v.as_str())

View File

@ -428,7 +428,7 @@ fn read_onion_address(service_name: &str) -> Option<String> {
if let Some(addr) = std::fs::read_to_string(&hostnames_dir)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| s.ends_with(".onion") && s.len() >= 60)
.filter(|s| s.ends_with(".onion") && s.len() == 62 && !s.contains(':') && s.chars().take(56).all(|c| c.is_ascii_alphanumeric()))
{
return Some(addr);
}
@ -453,7 +453,7 @@ fn read_onion_address(service_name: &str) -> Option<String> {
.and_then(|o| String::from_utf8(o.stdout).ok())
})
.map(|s| s.trim().to_string())
.filter(|s| s.ends_with(".onion") && s.len() >= 60)
.filter(|s| s.ends_with(".onion") && s.len() == 62 && !s.contains(':') && s.chars().take(56).all(|c| c.is_ascii_alphanumeric()))
{
return Some(addr);
}

View File

@ -7,7 +7,7 @@ Wants=network-online.target
Type=notify
User=archipelago
Environment="ARCHIPELAGO_BIND=0.0.0.0:5678"
Environment="ARCHIPELAGO_DEV_MODE=true"
# DEV_MODE disabled in production — enabled via override.conf on dev servers
Environment="XDG_RUNTIME_DIR=/run/user/1000"
ExecStartPre=/bin/bash -c 'mkdir -p /var/lib/archipelago && echo "ARCHIPELAGO_HOST_IP=$(hostname -I 2>/dev/null | awk "{print $$1}")" > /var/lib/archipelago/host-ip.env'
ExecStart=/usr/local/bin/archipelago

View File

@ -153,10 +153,12 @@ server {
}
location /lnd-connect-info {
# Requires authenticated session — exposes LND admin macaroon
if ($cookie_session_id = "") { return 401; }
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
proxy_http_version 1.1;
proxy_set_header Host $host;
add_header Access-Control-Allow-Origin *;
proxy_set_header Cookie $http_cookie;
}
# Content sharing — peer access over Tor (no auth)
@ -805,10 +807,12 @@ server {
}
location /lnd-connect-info {
# Requires authenticated session — exposes LND admin macaroon
if ($cookie_session_id = "") { return 401; }
proxy_pass http://127.0.0.1:5678/lnd-connect-info;
proxy_http_version 1.1;
proxy_set_header Host $host;
add_header Access-Control-Allow-Origin *;
proxy_set_header Cookie $http_cookie;
}
# Content sharing — peer access over Tor (no auth)

View File

@ -234,7 +234,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const remember = await requestConsent(title.value || 'App', 'signEvent', eventKind, content)
if (remember) saveApprovedOrigin(origin)
} catch {
source.postMessage({ type: 'nostr-response', id, error: 'User denied signing request' }, '*')
source.postMessage({ type: 'nostr-response', id, error: 'User denied signing request' }, origin || '*')
return
}
}
@ -278,10 +278,10 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
} else {
throw new Error(`Unsupported NIP-07 method: ${method}`)
}
source.postMessage({ type: 'nostr-response', id, result }, '*')
source.postMessage({ type: 'nostr-response', id, result }, origin || '*')
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
source.postMessage({ type: 'nostr-response', id, error: message }, '*')
source.postMessage({ type: 'nostr-response', id, error: message }, origin || '*')
}
}

View File

@ -635,10 +635,12 @@ async function handleNostrRequest(event: MessageEvent) {
else if (method === 'nip44.encrypt') { result = (await rpcClient.call<{ ciphertext: string }>({ method: 'identity.nostr-encrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext } })).ciphertext }
else if (method === 'nip44.decrypt') { result = (await rpcClient.call<{ plaintext: string }>({ method: 'identity.nostr-decrypt-nip44', params: { id: identityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext } })).plaintext }
else { throw new Error(`Unsupported NIP-07 method: ${method}`) }
source.postMessage({ type: 'nostr-response', id, result }, '*')
const targetOrigin = appUrl.value ? new URL(appUrl.value).origin : '*'
source.postMessage({ type: 'nostr-response', id, result }, targetOrigin)
} catch (err) {
if (import.meta.env.DEV) console.error(`[NIP-07] ${method} FAILED:`, err instanceof Error ? err.message : err)
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, '*')
const targetOrigin = appUrl.value ? new URL(appUrl.value).origin : '*'
source.postMessage({ type: 'nostr-response', id, error: err instanceof Error ? err.message : 'Unknown error' }, targetOrigin)
}
}