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:
parent
302f22019d
commit
0d28d28bf7
@ -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()
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 || '*')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user