Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"lint": "biome lint .",
"check": "biome check . && pnpm check:file-sizes && pnpm check:px-text && pnpm check:pubkey-truncation",
"format": "biome format --write .",
"test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\"",
"test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\" && node --import ./test-jsdom-setup.mjs --import ./test-loader.mjs --experimental-strip-types --test-force-exit --test \"src/**/*.jsdom-test.mjs\"",
"preview": "vite preview",
"tauri": "tauri",
"test:e2e": "pnpm build:e2e && playwright test",
Expand Down
2 changes: 2 additions & 0 deletions desktop/scripts/check-pubkey-truncation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const overrides = new Set([
"src/features/messages/lib/threadPanel.ts:395",
"src/features/projects/ui/ProjectsView.tsx:166",
"src/features/projects/ui/ProjectsOverviewPanel.tsx:209",
// Error message prefix in a console-internal action error (never rendered as identity).
"src/features/admin-console/AdminConsoleStaffingTab.tsx:108",
]);

await runPubkeyTruncationCheck({
Expand Down
100 changes: 100 additions & 0 deletions desktop/src-tauri/src/commands/admin/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//! Dedicated no-redirect HTTP client for admin API requests.
//!
//! A separate client (not the app-wide `http_client`) ensures that:
//! - 3xx responses are surfaced as errors rather than followed — preventing
//! redirect-hop SSRF where a relay-issued redirect could forward the NIP-98
//! `Authorization` header to an off-origin host.
//! - Timeouts are tuned for synchronous UI feedback rather than media downloads.

use std::sync::OnceLock;

/// Request timeout for admin API calls.
pub(crate) const ADMIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);

/// The module-level singleton admin HTTP client.
///
/// Built once via `OnceLock` — panics on build failure so there is no
/// silent fallback to a redirect-following client.
pub static ADMIN_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();

/// Initialise the admin client singleton. Must be called from `setup()` before
/// any admin command can be invoked. Subsequent calls are no-ops.
pub fn init_admin_client() {
ADMIN_CLIENT.get_or_init(|| {
reqwest::Client::builder()
.resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0)))
.pool_idle_timeout(std::time::Duration::from_secs(10))
.pool_max_idle_per_host(2)
.redirect(reqwest::redirect::Policy::none())
.timeout(ADMIN_TIMEOUT)
.build()
.expect(
"admin HTTP client must build with redirect::Policy::none(); \
a redirect-following fallback would forward the NIP-98 \
Authorization header across origins (redirect-hop SSRF)",
)
});
}

#[cfg(test)]
mod tests {
use super::*;

/// The admin client must be buildable and must refuse to follow redirects.
/// This mirrors the `build_media_fetch_client_succeeds_with_no_redirect_policy`
/// test in `media_download.rs`.
#[test]
fn admin_client_builds_with_no_redirect_policy() {
init_admin_client();
assert!(ADMIN_CLIENT.get().is_some());
}

/// A live test that the client does not follow a 302.
///
/// Mirrors `media_fetch_client_does_not_follow_redirects` in
/// `media_download.rs`. Serves a 302 pointing at the metadata endpoint
/// and asserts exactly one connection was accepted.
#[tokio::test]
async fn admin_client_does_not_follow_redirects() {
use std::io::{Read, Write};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

init_admin_client();
let client = ADMIN_CLIENT.get().expect("client initialised");

let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let connections = Arc::new(AtomicUsize::new(0));

let server_connections = Arc::clone(&connections);
let server = std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
server_connections.fetch_add(1, Ordering::SeqCst);
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
let response = "HTTP/1.1 302 Found\r\n\
Location: http://169.254.169.254/latest/meta-data/\r\n\
Content-Length: 0\r\n\
Connection: close\r\n\r\n";
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
});

let resp = client
.get(format!("http://{addr}/api/admin/v1/reports"))
.timeout(std::time::Duration::from_secs(5))
.send()
.await
.expect("request should complete without following the redirect");

assert_eq!(resp.status().as_u16(), 302);
server.join().unwrap();
assert_eq!(
connections.load(Ordering::SeqCst),
1,
"exactly one request must be issued — redirect must not be followed",
);
}
}
272 changes: 272 additions & 0 deletions desktop/src-tauri/src/commands/admin/helpers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
//! HTTP helpers for the desktop admin surface.
//!
//! NIP-98 authenticated fetch/mutation wrappers and response-reading utilities
//! used by the Tauri command implementations in `mod.rs`.

use super::client;
use super::{ATTACHMENT_CAP, ERROR_BODY_CAP};

/// Fetch a JSON endpoint with NIP-98 auth, one 401-retry, and a size cap.
pub(super) async fn fetch_admin_json(
url: &str,
cap: u64,
state: &tauri::State<'_, crate::app_state::AppState>,
) -> Result<Vec<u8>, String> {
use crate::relay::build_nip98_auth_header_for_keys;

let keys = state.signing_keys()?;
let http_client = client::ADMIN_CLIENT
.get()
.ok_or_else(|| "admin client not initialised".to_string())?;

let auth_header = build_nip98_auth_header_for_keys(&keys, &reqwest::Method::GET, url, &[])
.map_err(|e| format!("nip98 build failed: {e}"))?;

let resp = http_client
.get(url)
.header(reqwest::header::AUTHORIZATION, &auth_header)
.send()
.await
.map_err(|e| crate::relay::classify_request_error(&e))?;

// One retry on 401 with a fresh NIP-98 event (new nonce).
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
let auth_header2 = build_nip98_auth_header_for_keys(&keys, &reqwest::Method::GET, url, &[])
.map_err(|e| format!("nip98 build failed on retry: {e}"))?;
let resp2 = http_client
.get(url)
.header(reqwest::header::AUTHORIZATION, auth_header2)
.send()
.await
.map_err(|e| crate::relay::classify_request_error(&e))?;
return read_admin_response(resp2, cap, ERROR_BODY_CAP).await;
}

read_admin_response(resp, cap, ERROR_BODY_CAP).await
}

/// POST a JSON body with NIP-98 auth (payload sha256 in the tag), one 401-retry, size cap.
pub(super) async fn post_admin_json(
url: &str,
body: &[u8],
cap: u64,
state: &tauri::State<'_, crate::app_state::AppState>,
) -> Result<Vec<u8>, String> {
mutation_admin_json(reqwest::Method::POST, url, body, cap, state).await
}

/// PATCH a JSON body with NIP-98 auth (payload sha256), one 401-retry, size cap.
pub(super) async fn patch_admin_json(
url: &str,
body: &[u8],
cap: u64,
state: &tauri::State<'_, crate::app_state::AppState>,
) -> Result<Vec<u8>, String> {
mutation_admin_json(reqwest::Method::PATCH, url, body, cap, state).await
}

/// PUT a JSON body with NIP-98 auth (payload sha256), one 401-retry, size cap.
pub(super) async fn put_admin_json(
url: &str,
body: &[u8],
cap: u64,
state: &tauri::State<'_, crate::app_state::AppState>,
) -> Result<Vec<u8>, String> {
mutation_admin_json(reqwest::Method::PUT, url, body, cap, state).await
}

/// DELETE with NIP-98 auth (no body), one 401-retry, size cap.
pub(super) async fn delete_admin_json(
url: &str,
cap: u64,
state: &tauri::State<'_, crate::app_state::AppState>,
) -> Result<Vec<u8>, String> {
use crate::relay::build_nip98_auth_header_for_keys;

let keys = state.signing_keys()?;
let http_client = client::ADMIN_CLIENT
.get()
.ok_or_else(|| "admin client not initialised".to_string())?;

let auth_header = build_nip98_auth_header_for_keys(&keys, &reqwest::Method::DELETE, url, &[])
.map_err(|e| format!("nip98 build failed: {e}"))?;

let resp = http_client
.delete(url)
.header(reqwest::header::AUTHORIZATION, &auth_header)
.send()
.await
.map_err(|e| crate::relay::classify_request_error(&e))?;

if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
let auth_header2 =
build_nip98_auth_header_for_keys(&keys, &reqwest::Method::DELETE, url, &[])
.map_err(|e| format!("nip98 build failed on retry: {e}"))?;
let resp2 = http_client
.delete(url)
.header(reqwest::header::AUTHORIZATION, auth_header2)
.send()
.await
.map_err(|e| crate::relay::classify_request_error(&e))?;
return read_admin_response(resp2, cap, ERROR_BODY_CAP).await;
}

read_admin_response(resp, cap, ERROR_BODY_CAP).await
}

/// Shared implementation for POST/PATCH/PUT with NIP-98 payload sha256 binding.
pub(super) async fn mutation_admin_json(
method: reqwest::Method,
url: &str,
body: &[u8],
cap: u64,
state: &tauri::State<'_, crate::app_state::AppState>,
) -> Result<Vec<u8>, String> {
use crate::relay::build_nip98_auth_header_for_keys;

let keys = state.signing_keys()?;
let http_client = client::ADMIN_CLIENT
.get()
.ok_or_else(|| "admin client not initialised".to_string())?;

// NIP-98 §4: for body-bearing requests, include a `payload` tag over the
// SHA-256 of the exact request body bytes.
let auth_header = build_nip98_auth_header_for_keys(&keys, &method, url, body)
.map_err(|e| format!("nip98 build failed: {e}"))?;

let send_request = |auth: String| {
http_client
.request(method.clone(), url)
.header(reqwest::header::AUTHORIZATION, auth)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body.to_vec())
.send()
};

let resp = send_request(auth_header)
.await
.map_err(|e| crate::relay::classify_request_error(&e))?;

if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
let auth_header2 = build_nip98_auth_header_for_keys(&keys, &method, url, body)
.map_err(|e| format!("nip98 build failed on retry: {e}"))?;
let resp2 = send_request(auth_header2)
.await
.map_err(|e| crate::relay::classify_request_error(&e))?;
return read_admin_response(resp2, cap, ERROR_BODY_CAP).await;
}

read_admin_response(resp, cap, ERROR_BODY_CAP).await
}

/// Stream and validate an attachment response, enforcing Content-Type, size,
/// and the cap.
pub(super) async fn finish_attachment_response(
resp: reqwest::Response,
expected_mime: &str,
expected_size: u64,
) -> Result<tauri::ipc::Response, String> {
use futures_util::StreamExt;

if resp.status().is_redirection() {
return Err("admin_attachment_redirect".to_string());
}
if !resp.status().is_success() {
return Err(format!(
"admin_attachment_relay_error_{}",
resp.status().as_u16()
));
}

// Verify Content-Type before reading the body.
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
if content_type != expected_mime.trim().to_ascii_lowercase() {
return Err("admin_attachment_mime_mismatch".to_string());
}

// Content-Length preflight.
if let Some(cl) = resp.content_length() {
if cl > ATTACHMENT_CAP {
return Err("admin_attachment_too_large".to_string());
}
if cl != expected_size {
return Err("admin_attachment_size_mismatch".to_string());
}
}

// Stream with running byte counter.
let mut bytes: Vec<u8> = Vec::new();
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| "admin_attachment_stream_error".to_string())?;
if bytes.len() as u64 + chunk.len() as u64 > ATTACHMENT_CAP {
return Err("admin_attachment_too_large".to_string());
}
bytes.extend_from_slice(&chunk);
}

// Final size check.
if bytes.len() as u64 != expected_size {
return Err("admin_attachment_size_mismatch".to_string());
}

Ok(tauri::ipc::Response::new(bytes))
}

/// Read a response body up to `success_cap` bytes on 2xx, `error_cap` on
/// non-2xx. Redirects are treated as errors (the no-redirect client surfaced
/// them rather than following).
pub(super) async fn read_admin_response(
resp: reqwest::Response,
success_cap: u64,
error_cap: u64,
) -> Result<Vec<u8>, String> {
use futures_util::StreamExt;

if resp.status().is_redirection() {
return Err(format!(
"admin API returned a {} redirect (not followed)",
resp.status()
));
}

let (is_success, cap) = if resp.status().is_success() {
(true, success_cap)
} else {
(false, error_cap)
};

if let Some(cl) = resp.content_length() {
if cl > cap {
return Err(format!(
"admin response too large ({cl} bytes, cap {cap} bytes)"
));
}
}

let mut bytes: Vec<u8> = Vec::new();
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("admin response stream error: {e}"))?;
if bytes.len() as u64 + chunk.len() as u64 > cap {
return Err(format!("admin response too large (cap {cap} bytes)"));
}
bytes.extend_from_slice(&chunk);
}

if !is_success {
let body = String::from_utf8_lossy(&bytes);
return Err(format!("admin API error: {body}"));
}

Ok(bytes)
}
Loading