From 0d28d28bf70fcd31ce8cd8d2f86d1042ae78ae04 Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 18 Mar 2026 19:45:10 +0000 Subject: [PATCH] =?UTF-8?q?security(TASK-8):=20fix=208=20pentest=20finding?= =?UTF-8?q?s=20=E2=80=94=20C1/C3/H1/M1/M2/L2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- core/archipelago/src/api/handler.rs | 30 ++++++++++++++++----- core/archipelago/src/api/rpc/content.rs | 4 +++ core/archipelago/src/api/rpc/tor.rs | 4 +-- image-recipe/configs/archipelago.service | 2 +- image-recipe/configs/nginx-archipelago.conf | 8 ++++-- neode-ui/src/stores/appLauncher.ts | 6 ++--- neode-ui/src/views/AppSession.vue | 6 +++-- 7 files changed, 44 insertions(+), 16 deletions(-) diff --git a/core/archipelago/src/api/handler.rs b/core/archipelago/src/api/handler.rs index 66989595..90ce92c3 100644 --- a/core/archipelago/src/api/handler.rs +++ b/core/archipelago/src/api/handler.rs @@ -277,27 +277,45 @@ impl ApiHandler { struct Incoming { from_pubkey: Option, message: Option, + signature: Option, } 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() diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index 4767e5f3..1d3a6a25 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -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()) diff --git a/core/archipelago/src/api/rpc/tor.rs b/core/archipelago/src/api/rpc/tor.rs index 427696d8..64b0d4a6 100644 --- a/core/archipelago/src/api/rpc/tor.rs +++ b/core/archipelago/src/api/rpc/tor.rs @@ -428,7 +428,7 @@ fn read_onion_address(service_name: &str) -> Option { 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 { .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); } diff --git a/image-recipe/configs/archipelago.service b/image-recipe/configs/archipelago.service index 3fc72e94..c0a87d53 100644 --- a/image-recipe/configs/archipelago.service +++ b/image-recipe/configs/archipelago.service @@ -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 diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 6a66b714..1032df24 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -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) diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index 30f9e9c5..92faba01 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -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 || '*') } } diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue index a134a7f5..0e380860 100644 --- a/neode-ui/src/views/AppSession.vue +++ b/neode-ui/src/views/AppSession.vue @@ -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) } }