Network & API Security
This page covers protections at the transport, peer, HTTP middleware, and dashboard boundary layers.
Included Topics
- SSRF Protection
- OFP Mutual Authentication
- Security Headers
- GCRA Rate Limiter
- Health Endpoint Redaction
- Dashboard Authentication
SSRF Protection
Source: librefang-runtime/src/host_functions.rs
The host_net_fetch function (WASM host call for network requests) includes
comprehensive Server-Side Request Forgery protection.
Scheme Validation
Only http:// and https:// schemes are allowed. All others (file://,
gopher://, ftp://) are blocked immediately:
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(json!({"error": "Only http:// and https:// URLs are allowed"}));
}
Hostname Blocklist
Before DNS resolution, these hostnames are blocked:
localhostmetadata.google.internalmetadata.aws.internalinstance-data169.254.169.254(AWS/GCP metadata endpoint)
DNS Resolution Check
After the hostname blocklist, the function resolves the hostname to IP addresses and checks every resolved IP against private ranges. This defeats DNS rebinding attacks:
let socket_addr = format!("{hostname}:{port}");
if let Ok(addrs) = socket_addr.to_socket_addrs() {
for addr in addrs {
let ip = addr.ip();
if ip.is_loopback() || ip.is_unspecified() || is_private_ip(&ip) {
return Err(json!({"error": format!(
"SSRF blocked: {hostname} resolves to private IP {ip}"
)}));
}
}
}
Private IP Detection
The is_private_ip() function covers:
IPv4:
10.0.0.0/8-- RFC 1918172.16.0.0/12-- RFC 1918192.168.0.0/16-- RFC 1918169.254.0.0/16-- Link-local (AWS metadata)
IPv6:
fc00::/7-- Unique Local Addressfe80::/10-- Link-local
fn is_private_ip(ip: &std::net::IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
let octets = v4.octets();
matches!(
octets,
[10, ..] | [172, 16..=31, ..] | [192, 168, ..] | [169, 254, ..]
)
}
IpAddr::V6(v6) => {
let segments = v6.segments();
(segments[0] & 0xfe00) == 0xfc00 || (segments[0] & 0xffc0) == 0xfe80
}
}
}
Host Extraction
extract_host_from_url() parses the URL to extract host:port for both
SSRF checking and capability matching:
https://api.openai.com/v1/chat -> api.openai.com:443
http://localhost:8080/api -> localhost:8080
http://example.com -> example.com:80
OFP Mutual Authentication
Source: librefang-wire/src/peer.rs
The LibreFang Wire Protocol (OFP) authenticates every TCP connection in two layers:
- HMAC admission (
shared_secret) — coarse cluster-password gate. - Per-peer Ed25519 identity (#3873) — each
kernel persists its own Ed25519 keypair in
<data_dir>/peer_keypair.jsonand signs the handshake. Recipients TOFU-pin the pubkey to the sender'snode_id(persisted in<data_dir>/trusted_peers.json) and reject any future handshake that claims the samenode_idwith a different pubkey.
A leaked shared_secret therefore no longer lets an attacker
impersonate a previously-pinned peer — they would also need that
peer's private key file.
Pre-Shared Key Requirement
OFP refuses to start without a shared_secret:
if config.shared_secret.is_empty() {
return Err(WireError::HandshakeFailed(
"OFP requires shared_secret. Set [network] shared_secret in config.toml".into(),
));
}
HMAC Functions
type HmacSha256 = Hmac<Sha256>;
fn hmac_sign(secret: &str, data: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.expect("HMAC accepts any key size");
mac.update(data);
hex::encode(mac.finalize().into_bytes())
}
fn hmac_verify(secret: &str, data: &[u8], signature: &str) -> bool {
let expected = hmac_sign(secret, data);
subtle::ConstantTimeEq::ct_eq(expected.as_bytes(), signature.as_bytes()).into()
}
Constant-time comparison (subtle::ConstantTimeEq) prevents
timing side-channel attacks.
Handshake Protocol
The same auth_data byte string is bound by both the HMAC and the
Ed25519 signature, so identity verification piggybacks on the existing
replay protection without protocol-level race windows.
auth_data = nonce | sender_node_id | recipient_node_id // #3875
Initiator (client):
- Generate a random UUID nonce.
- Compute
auth_data(above) using the expectedrecipient_node_id. - Compute
auth_hmac = hmac_sign(shared_secret, auth_data). - If this kernel has an Ed25519 identity loaded:
identity_signature = ed25519_sign(private_key, auth_data)and includepublic_key(base64) plusidentity_signature(base64) in the handshake message. - Send
Handshake { node_id, node_name, protocol_version, agents, nonce, auth_hmac, public_key?, identity_signature? }.
Responder (server):
- Receive the
Handshakemessage. - Verify the incoming HMAC over
auth_data. Fail → 403. - Verify the Ed25519 identity (#3873):
- (public_key, identity_signature) both present:
ed25519_verify(public_key, auth_data, identity_signature)MUST succeed. Then enforce TOFU pinning:- If
node_idis already pinned to a different pubkey → reject. - If new → pin pubkey to
node_id(in memory + persistenttrusted_peers.json).
- If
- Both absent (legacy peer): allowed only if no pin exists for
this
node_id; if a pin exists, the dropped identity is treated as a downgrade attack and rejected. - Mixed presence: rejected (malformed).
- (public_key, identity_signature) both present:
- Record nonce in the replay tracker (HMAC- and identity-verified first; ordering fix for #3880).
- Generate a new UUID
ack_nonceandack_auth_data = ack_nonce | self.node_id | sender.node_id. ack_hmac = hmac_sign(shared_secret, ack_auth_data).- If this kernel has an Ed25519 identity loaded, sign
ack_auth_datathe same way and includepublic_key+identity_signature. - Send
HandshakeAck { ..., nonce: ack_nonce, auth_hmac: ack_hmac, public_key?, identity_signature? }.
Initiator (verification):
- Receive
HandshakeAck. - Verify the ack HMAC over the corresponding
ack_auth_data. - Verify the ack's Ed25519 identity using the same TOFU rules as the responder above.
- Both checks must pass before any subsequent message is processed.
Security Properties
| Property | How It Is Achieved |
|---|---|
| Mutual admission | Both sides prove knowledge of shared_secret |
| Per-peer identity (#3873) | Ed25519 sign-on-handshake + TOFU pin in trusted_peers.json |
| Cross-peer replay protection (#3875) | auth_data binds recipient_node_id |
| Replay protection | Random UUID nonces per handshake, time-windowed tracker |
| Timing-attack resistance | subtle::ConstantTimeEq for HMAC comparison; constant-time Ed25519 verify |
| Mandatory secret | OFP refuses to start with an empty shared_secret |
| Message size limit | MAX_MESSAGE_SIZE = 16 MB prevents memory DoS |
| Protocol version check | PROTOCOL_VERSION mismatch returns WireError::VersionMismatch |
| Pin map DoS bound | MAX_PIN_ENTRIES = 100_000; hydration also capped |
| Trust file confidentiality | peer_keypair.json and trusted_peers.json written with 0600 on Unix |
Security Headers
Source: librefang-api/src/middleware.rs
The security_headers middleware is applied to all API responses:
pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Body> {
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert("x-content-type-options", "nosniff".parse().unwrap());
headers.insert("x-frame-options", "DENY".parse().unwrap());
headers.insert("x-xss-protection", "1; mode=block".parse().unwrap());
headers.insert("content-security-policy", /* CSP policy */);
headers.insert("referrer-policy", "strict-origin-when-cross-origin".parse().unwrap());
headers.insert("cache-control", "no-store, no-cache, must-revalidate".parse().unwrap());
response
}
| Header | Value | Protects Against |
|---|---|---|
X-Content-Type-Options | nosniff | MIME type sniffing attacks |
X-Frame-Options | DENY | Clickjacking via iframes |
X-XSS-Protection | 1; mode=block | Reflected XSS (legacy browsers) |
Content-Security-Policy | See below | XSS, code injection, data exfiltration |
Referrer-Policy | strict-origin-when-cross-origin | Referrer leakage |
Cache-Control | no-store, no-cache, must-revalidate | Sensitive data caching |
CSP Breakdown
| Directive | Value | Purpose |
|---|---|---|
default-src | 'self' | Deny all external resources by default |
script-src | 'self' 'unsafe-inline' 'unsafe-eval' cdn.jsdelivr.net | Allow scripts from self and CDN |
style-src | 'self' 'unsafe-inline' cdn.jsdelivr.net fonts.googleapis.com | Allow styles from self, CDN, Google Fonts |
img-src | 'self' data: | Allow images from self and data URIs |
connect-src | 'self' ws: wss: | Allow WebSocket connections |
font-src | 'self' cdn.jsdelivr.net fonts.gstatic.com | Allow fonts from CDN |
object-src | 'none' | Block all plugins (Flash, Java, etc.) |
base-uri | 'self' | Prevent base tag hijacking |
form-action | 'self' | Restrict form submission targets |
GCRA Rate Limiter
Source: librefang-api/src/rate_limiter.rs
LibreFang uses the Generic Cell Rate Algorithm (GCRA) for cost-aware API
rate limiting via the governor crate.
Algorithm
GCRA is a leaky-bucket variant that tracks a single "virtual scheduling time" (TAT -- Theoretical Arrival Time) per key. Each request consumes a number of tokens proportional to its cost. The bucket refills at a constant rate.
Budget: 500 tokens per minute per IP address.
pub fn create_rate_limiter() -> Arc<KeyedRateLimiter> {
Arc::new(RateLimiter::keyed(Quota::per_minute(NonZeroU32::new(500).unwrap())))
}
Operation Costs
Each API operation has a configurable token cost:
pub fn operation_cost(method: &str, path: &str) -> NonZeroU32 {
match (method, path) {
(_, "/api/health") => 1,
("GET", "/api/status") => 1,
("GET", "/api/version") => 1,
("GET", "/api/tools") => 1,
("GET", "/api/agents") => 2,
("GET", "/api/skills") => 2,
("GET", "/api/peers") => 2,
("GET", "/api/config") => 2,
("GET", "/api/usage") => 3,
("GET", p) if p.starts_with("/api/audit") => 5,
("GET", p) if p.starts_with("/api/marketplace")=> 10,
("POST", "/api/agents") => 50,
("POST", p) if p.contains("/message") => 30,
("POST", p) if p.contains("/run") => 100,
("POST", "/api/skills/install") => 50,
("POST", "/api/skills/uninstall") => 10,
("POST", "/api/migrate") => 100,
("PUT", p) if p.contains("/update") => 10,
_ => 5,
}
}
The cost hierarchy is intentional: read-only health checks cost 1 token while expensive operations like workflow runs cost 100, meaning a client can perform 500 health checks per minute but only 5 workflow runs.
Middleware
pub async fn gcra_rate_limit(
State(limiter): State<Arc<KeyedRateLimiter>>,
request: Request<Body>,
next: Next,
) -> Response<Body> {
let ip = /* extract from ConnectInfo, default 127.0.0.1 */;
let cost = operation_cost(&method, &path);
if limiter.check_key_n(&ip, cost).is_err() {
tracing::warn!(ip, cost, path, "GCRA rate limit exceeded");
return Response::builder()
.status(StatusCode::TOO_MANY_REQUESTS)
.header("retry-after", "60")
.body(/* JSON error */)
.unwrap_or_default();
}
next.run(request).await
}
Rate Limiter Type
pub type KeyedRateLimiter = RateLimiter<IpAddr, DashMapStateStore<IpAddr>, DefaultClock>;
The DashMapStateStore provides concurrent per-IP state with automatic stale
entry cleanup.
Health Endpoint Redaction
Source: librefang-api/src/routes.rs
LibreFang provides two health endpoints with different information levels.
Public Endpoint: GET /api/health
No authentication required. Returns only liveness information:
{
"status": "ok",
"version": "0.1.0"
}
This endpoint does not expose agent count, database details, configuration warnings, uptime, or any internal system information. It is suitable for load balancer health checks.
Detail Endpoint: GET /api/health/detail
Requires authentication. Returns full diagnostics:
{
"status": "ok",
"version": "0.1.0",
"uptime_seconds": 3600,
"panic_count": 0,
"restart_count": 2,
"agent_count": 15,
"database": "connected",
"config_warnings": []
}
Localhost Fallback
When no API key is configured, the auth middleware restricts all
non-health endpoints to loopback addresses only:
if api_key.is_empty() {
let is_loopback = request.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|ci| ci.0.ip().is_loopback())
.unwrap_or(false);
if !is_loopback {
return Response::builder()
.status(StatusCode::FORBIDDEN)
.body(/* "No API key configured. Remote access denied." */)
...;
}
}
Dashboard Authentication
LibreFang supports optional username/password authentication for the web dashboard.
Configuration
# config.toml
dashboard_user = "admin"
dashboard_pass = "vault:dashboard_password"
When both fields are set, the dashboard displays a login page. Without credentials configured, the dashboard is accessible without login (or protected by api_key if set).
Credential Sources (Priority Order)
| Priority | Source | Example |
|---|---|---|
| 1 | Environment variable | LIBREFANG_DASHBOARD_PASS=secret |
| 2 | Encrypted vault | dashboard_pass = "vault:dashboard_password" |
| 3 | Literal in config | dashboard_pass = "my-password" |
Vault Storage (Recommended)
# Store password in encrypted vault
librefang vault set dashboard_password
# Reference in config.toml
dashboard_pass = "vault:dashboard_password"
The vault uses AES-256-GCM encryption with a master key stored in the OS keyring (macOS Keychain / Windows Credential Manager / Linux Secret Service) or via the LIBREFANG_VAULT_KEY environment variable.
Security Properties
- Constant-time comparison: Credentials are compared using
subtle::ConstantTimeEqto prevent timing attacks. - HMAC session token: On successful login, a deterministic HMAC-SHA256 token is derived from the credentials and returned as a Bearer token for subsequent API requests.
- No server-side sessions: The token is deterministic, so the server does not need to store session state.
- Public endpoints:
/api/auth/dashboard-loginand/api/auth/dashboard-checkare accessible without authentication.