foundation_keychain 0.1.7

Bitwarden-compatible vault + SSH key provisioning built on foundation_db and foundation_auth
//! Icons API — SSRF-guarded favicon proxy (spec-57, F008 Stage 1).
//!
//! WHY: Bitwarden clients fetch site icons through the server so the client never
//! talks to arbitrary hosts directly. The server must refuse to be used as an
//! SSRF gadget against internal addresses. Ported from OrangeVault `api/icons.rs`.
//!
//! WHAT: `fetch_icon(domain)` validates the domain, fetches
//! `https://<domain>/favicon.ico` over the platform network client
//! (`HttpClientBuilder`: the native client, or the fetch client on wasm32), and
//! returns the bytes + content type. Caching is a backend concern (KV / R2) layered
//! on by the transport.
//!
//! HOW: the SSRF guard rejects userinfo/port/path/IP-literal/`localhost`/`.local`
//! shapes before any network call, via [`crate::core::util::validate_icon_domain`]
//! plus explicit structural checks.

use foundation_netio::shared::client::body_reader::try_collect_bytes;
use foundation_netio::shared::http::SimpleHeader;
use foundation_netio::{HttpClientBuilder, PreparedRequestBuilder};

use crate::core::error::{AppError, AppResult};
use crate::core::util::validate_icon_domain;

/// A fetched favicon.
#[derive(Debug, Clone)]
pub struct IconData {
    pub content_type: String,
    pub bytes: Vec<u8>,
}

/// Reject anything that isn't a bare registrable hostname before we build a URL.
fn is_plausible_domain(domain: &str) -> bool {
    !domain.is_empty()
        && domain.len() <= 253
        && !domain.contains('/')
        && !domain.contains('@')
        && !domain.contains(':')
        && !domain.contains("..")
        && domain.contains('.')
        && domain.parse::<std::net::IpAddr>().is_err()
}

/// `GET /icons/:domain/icon.png` — fetch a site's favicon (SSRF-guarded).
///
/// # Errors
///
/// Returns an error if the operation fails.
pub async fn fetch_icon(domain: &str) -> AppResult<IconData> {
    let domain = domain.trim().to_lowercase();
    if !is_plausible_domain(&domain) {
        return Err(AppError::BadRequest("invalid icon domain".into()).into());
    }
    let url = format!("https://{domain}/favicon.ico");
    if !validate_icon_domain(&url) {
        return Err(AppError::Forbidden.into());
    }

    let req = PreparedRequestBuilder::get(&url)
        .map_err(|e| AppError::BadRequest(format!("invalid domain URL: {e}")))?
        .header(SimpleHeader::ACCEPT, "image/*")
        .build();
    // Belt-and-suspenders: the parsed URL must still be a plain https host.
    if req.url.host_str().as_deref() != Some(domain.as_str()) || req.url.port().is_some() {
        return Err(AppError::Forbidden.into());
    }

    let client = HttpClientBuilder::new().build();

    let resp = client
        .send_async(req)
        .await
        .map_err(|e| AppError::NotFound(format!("icon fetch failed: {e}")))?;
    let status: usize = resp.get_status().into();
    if !(200..300).contains(&status) {
        return Err(AppError::NotFound(format!("icon not found (HTTP {status})")).into());
    }
    let content_type = resp
        .get_headers_ref()
        .get(&SimpleHeader::CONTENT_TYPE)
        .and_then(|v| v.first())
        .cloned()
        .unwrap_or_else(|| "image/x-icon".to_string());
    let bytes = try_collect_bytes(resp.take_body())
        .map_err(|e| AppError::NotFound(format!("read icon body: {e}")))?;

    Ok(IconData {
        content_type,
        bytes,
    })
}