mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
fix(http): account trusted proxy client addresses
Reverse-proxied production deployments currently attribute every WebSocket connection to the proxy peer, collapsing per-IP limits, abuse metrics, and logs onto localhost. Blindly trusting forwarding headers would let direct clients spoof the same controls. Add an opt-in CIDR trust boundary and resolve X-Forwarded-For, Forwarded, or X-Real-IP only when the TCP peer is trusted. Walk proxy chains from the peer inward, stop at the first untrusted hop, and fall back to the peer on malformed input. Feed the resolved address consistently into rust-nostr connection policy, connection metrics, and lifecycle logs. Expose the setting across CLI/environment, NixOS, examples, and reference documentation. Multi-hop correctness assumes every trusted proxy appends or overwrites the forwarding chain and the backend is unreachable from untrusted networks. HTTP Git request accounting and PROXY protocol support remain out of scope. Unit coverage exercises direct clients, trusted single and multi-proxy paths, spoofed headers, malformed chains, IPv6, and configuration parsing.
This commit is contained in:
@@ -26,6 +26,16 @@
|
||||
# Default: 127.0.0.1:7334
|
||||
# NGIT_BIND_ADDRESS=127.0.0.1:7334
|
||||
|
||||
# Trusted reverse-proxy address ranges (comma-separated IPv4/IPv6 CIDRs)
|
||||
# Forwarded, X-Forwarded-For, and X-Real-IP are ignored unless the TCP peer
|
||||
# matches one of these ranges. Keep empty for direct/public listeners.
|
||||
# When trusting a proxy, keep the ngit-grasp backend unreachable from
|
||||
# untrusted networks and include every trusted hop in a forwarding chain.
|
||||
# CLI: --trusted-proxy-cidrs <cidrs>
|
||||
# Default: (empty)
|
||||
# Example for a reverse proxy on the same host:
|
||||
# NGIT_TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128
|
||||
|
||||
# ============================================================================
|
||||
# RELAY INFORMATION (NIP-11)
|
||||
# ============================================================================
|
||||
|
||||
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- Added `trusted_proxy_cidrs` to the public `Config` struct. Rust consumers
|
||||
that construct `Config` with a struct literal must provide it.
|
||||
|
||||
### Security
|
||||
|
||||
- Added opt-in trusted-proxy CIDRs so WebSocket connection policy, per-IP
|
||||
metrics, abuse indicators, and logs can use the real client address without
|
||||
trusting spoofable forwarding headers from arbitrary direct peers.
|
||||
|
||||
### Added
|
||||
|
||||
- Add bounded, operator-configurable inbox fallback coverage when a successful
|
||||
|
||||
Generated
+4
@@ -1163,6 +1163,9 @@ name = "ipnet"
|
||||
version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
@@ -1333,6 +1336,7 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"indexmap",
|
||||
"ipnet",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"nostr",
|
||||
|
||||
@@ -37,6 +37,7 @@ base64 = "0.22"
|
||||
flate2 = "1.0"
|
||||
tar = "0.4"
|
||||
fs2 = "0.4"
|
||||
ipnet = { version = "2", features = ["serde"] }
|
||||
libc = "0.2"
|
||||
|
||||
# Metrics
|
||||
|
||||
@@ -450,16 +450,17 @@ passes the key through a protected systemd credential.
|
||||
|
||||
#### Core Settings
|
||||
|
||||
| Option | CLI Flag | Environment Variable | Default |
|
||||
| ----------------- | --------------------- | ------------------------ | -------------------------------------------- |
|
||||
| Domain | `--domain` | `NGIT_DOMAIN` | (required) |
|
||||
| Relay owner nsec | — | `NGIT_RELAY_OWNER_NSEC` | systemd credential, then `.relay-owner.nsec` |
|
||||
| Relay name | `--relay-name` | `NGIT_RELAY_NAME` | `${domain} grasp relay` |
|
||||
| Relay description | `--relay-description` | `NGIT_RELAY_DESCRIPTION` | `Git Nostr Relay - a grasp implementation` |
|
||||
| Git data path | `--git-data-path` | `NGIT_GIT_DATA_PATH` | `./data/git` (temp dir for memory backend) |
|
||||
| Relay data path | `--relay-data-path` | `NGIT_RELAY_DATA_PATH` | `./data/relay` (temp dir for memory backend) |
|
||||
| Bind address | `--bind-address` | `NGIT_BIND_ADDRESS` | `127.0.0.1:7334` (NGIT on phone keypad) |
|
||||
| Database backend | `--database-backend` | `NGIT_DATABASE_BACKEND` | `lmdb` |
|
||||
| Option | CLI Flag | Environment Variable | Default |
|
||||
| ------------------- | ------------------------ | --------------------------- | -------------------------------------------- |
|
||||
| Domain | `--domain` | `NGIT_DOMAIN` | (required) |
|
||||
| Relay owner nsec | — | `NGIT_RELAY_OWNER_NSEC` | systemd credential, then `.relay-owner.nsec` |
|
||||
| Relay name | `--relay-name` | `NGIT_RELAY_NAME` | `${domain} grasp relay` |
|
||||
| Relay description | `--relay-description` | `NGIT_RELAY_DESCRIPTION` | `Git Nostr Relay - a grasp implementation` |
|
||||
| Git data path | `--git-data-path` | `NGIT_GIT_DATA_PATH` | `./data/git` (temp dir for memory backend) |
|
||||
| Relay data path | `--relay-data-path` | `NGIT_RELAY_DATA_PATH` | `./data/relay` (temp dir for memory backend) |
|
||||
| Bind address | `--bind-address` | `NGIT_BIND_ADDRESS` | `127.0.0.1:7334` (NGIT on phone keypad) |
|
||||
| Trusted proxy CIDRs | `--trusted-proxy-cidrs` | `NGIT_TRUSTED_PROXY_CIDRS` | (empty; forwarded headers ignored) |
|
||||
| Database backend | `--database-backend` | `NGIT_DATABASE_BACKEND` | `lmdb` |
|
||||
|
||||
#### GRASP-02 Sync Settings
|
||||
|
||||
@@ -515,6 +516,10 @@ export NGIT_RELAY_OWNER_NSEC=nsec1... # Or let it auto-generate from .relay-own
|
||||
export NGIT_BIND_ADDRESS=0.0.0.0:7334
|
||||
export NGIT_DATABASE_BACKEND=lmdb
|
||||
|
||||
# When a private backend is reached only through a reverse proxy:
|
||||
# export NGIT_BIND_ADDRESS=127.0.0.1:7334
|
||||
# export NGIT_TRUSTED_PROXY_CIDRS=127.0.0.1/32
|
||||
|
||||
# Optional: Enable proactive sync from a bootstrap relay
|
||||
export NGIT_SYNC_BOOTSTRAP_RELAY_URL=wss://relay.damus.io
|
||||
|
||||
|
||||
+11
-2
@@ -72,6 +72,8 @@ Create a new file for your ngit-grasp service (e.g., `services/ngit-grasp.nix`):
|
||||
# Network
|
||||
bindAddress = "127.0.0.1";
|
||||
port = 8082;
|
||||
# Only Caddy can reach the loopback backend, so its forwarded client IP is trusted.
|
||||
trustedProxyCidrs = [ "127.0.0.1/32" ];
|
||||
|
||||
# Storage
|
||||
dataDir = "/persistent/ngit-grasp";
|
||||
@@ -95,8 +97,8 @@ Create a new file for your ngit-grasp service (e.g., `services/ngit-grasp.nix`):
|
||||
services.caddy.virtualHosts."ngit.example.com" = {
|
||||
extraConfig = ''
|
||||
reverse_proxy 127.0.0.1:8082 {
|
||||
header_down X-Real-IP {http.request.remote}
|
||||
header_down X-Forwarded-For {http.request.remote}
|
||||
# Caddy manages X-Forwarded-For automatically.
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
'';
|
||||
};
|
||||
@@ -108,6 +110,11 @@ Create a new file for your ngit-grasp service (e.g., `services/ngit-grasp.nix`):
|
||||
- **Instance name** (`production`): Can be any name. Used for systemd service (`ngit-grasp-production`)
|
||||
- **domain**: Your relay's domain (used in GRASP validation)
|
||||
- **port**: Local port (use reverse proxy for HTTPS)
|
||||
- **trustedProxyCidrs**: Proxy source ranges allowed to supply the client IP
|
||||
- Keep empty for a directly exposed listener
|
||||
- Keep the backend private; trusting a public-facing source range permits spoofed headers
|
||||
- Caddy automatically maintains `X-Forwarded-For`; `header_up`, not `header_down`,
|
||||
changes headers sent to the backend
|
||||
- **dataDir**: Where git repos and database are stored
|
||||
- **relayOwnerNsecFile**: Path to file containing relay owner's nsec
|
||||
- Passed to ngit-grasp as a protected systemd credential, not a process argument
|
||||
@@ -268,6 +275,8 @@ git ls-remote https://ngit.example.com/<npub>/<repo>.git
|
||||
### Network
|
||||
- `bindAddress` - IP to bind to (default: "127.0.0.1")
|
||||
- `port` - Port to listen on (default: 7334)
|
||||
- `trustedProxyCidrs` - Proxy networks allowed to provide the WebSocket client
|
||||
IP (default: empty; forwarded headers ignored)
|
||||
|
||||
### Storage
|
||||
- `dataDir` - Base directory for data (default: /var/lib/ngit-grasp-{name})
|
||||
|
||||
@@ -47,11 +47,60 @@ NGIT_BIND_ADDRESS=127.0.0.1:3000
|
||||
**Notes:**
|
||||
|
||||
- Use `127.0.0.1` for local development
|
||||
- Use `0.0.0.0` for production (behind reverse proxy)
|
||||
- Prefer a loopback or private address behind a production reverse proxy
|
||||
- Use `0.0.0.0` only when the listener must be directly reachable
|
||||
- Ensure firewall rules allow the port
|
||||
|
||||
---
|
||||
|
||||
#### `NGIT_TRUSTED_PROXY_CIDRS`
|
||||
|
||||
**Description:** Comma-separated IPv4 or IPv6 networks whose forwarding
|
||||
headers may identify a WebSocket client
|
||||
|
||||
**Type:** CIDR list
|
||||
|
||||
**Default:** Empty
|
||||
|
||||
**Required:** No
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Caddy or nginx connects over IPv4 loopback
|
||||
NGIT_TRUSTED_PROXY_CIDRS=127.0.0.1/32
|
||||
|
||||
# Reverse proxy may connect over either loopback family
|
||||
NGIT_TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128
|
||||
|
||||
# Two private proxy tiers
|
||||
NGIT_TRUSTED_PROXY_CIDRS=10.10.0.0/24,10.20.0.0/24
|
||||
```
|
||||
|
||||
When the TCP peer is in one of these networks, ngit-grasp resolves the client
|
||||
from `X-Forwarded-For`, `Forwarded`, or `X-Real-IP`, in that order. Forwarding
|
||||
chains are walked from the TCP peer inward and stop at the first untrusted hop.
|
||||
This resolved IP is used for relay connection policy, per-IP connection
|
||||
metrics, abuse indicators, and WebSocket connection logs.
|
||||
|
||||
Forwarding headers are always ignored for untrusted TCP peers. Malformed
|
||||
headers also fall back to the peer address rather than a less authoritative
|
||||
header. Invalid CIDRs stop startup.
|
||||
|
||||
**Security requirements:**
|
||||
|
||||
- Leave this empty for a directly exposed ngit-grasp listener.
|
||||
- Configure the reverse proxy to append or overwrite forwarding headers rather
|
||||
than passing client-controlled values unchanged.
|
||||
- Bind ngit-grasp to loopback/private interfaces or firewall the backend so
|
||||
untrusted clients cannot connect from an address included in this list.
|
||||
- Include every proxy hop that should be traversed. The first address outside
|
||||
the trusted ranges is treated as the client.
|
||||
|
||||
The corresponding NixOS option is `trustedProxyCidrs`.
|
||||
|
||||
---
|
||||
|
||||
#### `NGIT_DOMAIN`
|
||||
|
||||
**Description:** Public domain name for this GRASP instance
|
||||
@@ -1568,13 +1617,16 @@ NGIT_RELAY_NAME="GitNostr Public Relay"
|
||||
NGIT_RELAY_DESCRIPTION="Public GRASP relay for open source projects"
|
||||
NGIT_GIT_DATA_PATH=/var/lib/ngit-grasp/git
|
||||
NGIT_RELAY_DATA_PATH=/var/lib/ngit-grasp/relay
|
||||
NGIT_BIND_ADDRESS=0.0.0.0:7334
|
||||
NGIT_BIND_ADDRESS=127.0.0.1:7334
|
||||
NGIT_TRUSTED_PROXY_CIDRS=127.0.0.1/32
|
||||
RUST_LOG=info,ngit_grasp=debug
|
||||
```
|
||||
|
||||
**Additional production considerations:**
|
||||
|
||||
- Use reverse proxy (nginx, Caddy) for HTTPS
|
||||
- Keep the backend private and set `NGIT_TRUSTED_PROXY_CIDRS` to the actual
|
||||
proxy source networks if client-IP accounting is required
|
||||
- Set up log rotation
|
||||
- Configure monitoring
|
||||
- Implement backup strategy
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
# Network
|
||||
bindAddress = "127.0.0.1";
|
||||
port = 8082;
|
||||
# Trust forwarding headers only from the loopback Caddy connection.
|
||||
# Keep this empty if ngit-grasp is directly exposed.
|
||||
trustedProxyCidrs = [ "127.0.0.1/32" ];
|
||||
|
||||
# Storage
|
||||
dataDir = "/persistent/ngit-danconwaydev-com-ngit-grasp";
|
||||
@@ -58,8 +61,8 @@
|
||||
services.caddy.virtualHosts."ngit.danconwaydev.com" = {
|
||||
extraConfig = ''
|
||||
reverse_proxy 127.0.0.1:8082 {
|
||||
header_down X-Real-IP {http.request.remote}
|
||||
header_down X-Forwarded-For {http.request.remote}
|
||||
# Caddy manages X-Forwarded-For automatically.
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -41,6 +41,19 @@ let
|
||||
description = "IP address to bind to";
|
||||
};
|
||||
|
||||
trustedProxyCidrs = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
example = [ "127.0.0.1/32" "::1/128" "10.0.0.0/8" ];
|
||||
description = ''
|
||||
IP address ranges for reverse proxies whose Forwarded,
|
||||
X-Forwarded-For, or X-Real-IP headers may identify WebSocket
|
||||
clients. Leave empty unless ngit-grasp is behind a trusted proxy.
|
||||
Keep the backend unreachable from untrusted networks and include
|
||||
every trusted proxy hop needed to resolve a forwarding chain.
|
||||
'';
|
||||
};
|
||||
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 7334;
|
||||
@@ -552,6 +565,8 @@ let
|
||||
concatStringsSep "," cfg.syncPlusFallbackRelays;
|
||||
} // optionalAttrs (cfg.maxConnections != null) {
|
||||
NGIT_MAX_CONNECTIONS = toString cfg.maxConnections;
|
||||
} // optionalAttrs (cfg.trustedProxyCidrs != [ ]) {
|
||||
NGIT_TRUSTED_PROXY_CIDRS = concatStringsSep "," cfg.trustedProxyCidrs;
|
||||
} // optionalAttrs (cfg.relayName != null) {
|
||||
NGIT_RELAY_NAME = cfg.relayName;
|
||||
} // optionalAttrs (cfg.archiveReadOnly != null) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use clap::{Parser, ValueEnum};
|
||||
use ipnet::IpNet;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
@@ -360,6 +361,12 @@ pub struct Config {
|
||||
#[arg(long, env = "NGIT_BIND_ADDRESS", default_value = "127.0.0.1:7334")]
|
||||
pub bind_address: String,
|
||||
|
||||
/// Proxy address ranges allowed to supply client IP forwarding headers.
|
||||
///
|
||||
/// Empty by default so forwarded headers from direct clients are ignored.
|
||||
#[arg(long, env = "NGIT_TRUSTED_PROXY_CIDRS", value_delimiter = ',')]
|
||||
pub trusted_proxy_cidrs: Vec<IpNet>,
|
||||
|
||||
/// Database backend type
|
||||
#[arg(long, env = "NGIT_DATABASE_BACKEND", value_enum, default_value_t = DatabaseBackend::Lmdb)]
|
||||
pub database_backend: DatabaseBackend,
|
||||
@@ -1125,6 +1132,7 @@ impl Config {
|
||||
git_data_path: "./test_data/git".to_string(),
|
||||
relay_data_path: "./test_data/relay".to_string(),
|
||||
bind_address: "127.0.0.1:7334".to_string(),
|
||||
trusted_proxy_cidrs: Vec::new(),
|
||||
database_backend: DatabaseBackend::Memory,
|
||||
metrics_enabled: true,
|
||||
metrics_connection_per_ip_abuse_threshold: 10,
|
||||
@@ -1213,6 +1221,7 @@ fn secure_secret_file_permissions(path: &Path) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static CONFIG_ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
@@ -1222,10 +1231,60 @@ mod tests {
|
||||
let config = Config::for_testing();
|
||||
assert_eq!(config.domain, "localhost:7334");
|
||||
assert_eq!(config.bind_address, "127.0.0.1:7334");
|
||||
assert!(config.trusted_proxy_cidrs.is_empty());
|
||||
// for_testing() uses Memory, but the actual default is Lmdb
|
||||
assert_eq!(config.database_backend, DatabaseBackend::Memory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trusted_proxy_cidrs_parse_from_cli() {
|
||||
let config = Config::try_parse_from([
|
||||
"ngit-grasp",
|
||||
"--domain",
|
||||
"example.com",
|
||||
"--trusted-proxy-cidrs",
|
||||
"127.0.0.1/32,10.0.0.0/8,2001:db8::/32",
|
||||
])
|
||||
.expect("trusted proxy CIDRs should parse");
|
||||
|
||||
assert_eq!(config.trusted_proxy_cidrs.len(), 3);
|
||||
assert!(config.trusted_proxy_cidrs[0].contains(&"127.0.0.1".parse::<IpAddr>().unwrap()));
|
||||
assert!(config.trusted_proxy_cidrs[1].contains(&"10.2.3.4".parse::<IpAddr>().unwrap()));
|
||||
assert!(config.trusted_proxy_cidrs[2].contains(&"2001:db8::1".parse::<IpAddr>().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trusted_proxy_cidrs_reject_invalid_networks() {
|
||||
let error = Config::try_parse_from([
|
||||
"ngit-grasp",
|
||||
"--domain",
|
||||
"example.com",
|
||||
"--trusted-proxy-cidrs",
|
||||
"127.0.0.1/32,not-a-network",
|
||||
])
|
||||
.expect_err("invalid trusted proxy CIDRs must stop startup");
|
||||
|
||||
assert!(error.to_string().contains("not-a-network"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trusted_proxy_cidrs_parse_from_environment() {
|
||||
const VARIABLE: &str = "NGIT_TRUSTED_PROXY_CIDRS";
|
||||
let _environment_guard = CONFIG_ENV_LOCK.lock().expect("lock must not be poisoned");
|
||||
let original = std::env::var_os(VARIABLE);
|
||||
std::env::set_var(VARIABLE, "127.0.0.1/32,::1/128");
|
||||
|
||||
let result = Config::try_parse_from(["ngit-grasp", "--domain", "example.com"]);
|
||||
|
||||
match original {
|
||||
Some(value) => std::env::set_var(VARIABLE, value),
|
||||
None => std::env::remove_var(VARIABLE),
|
||||
}
|
||||
|
||||
let config = result.expect("trusted proxy CIDRs should parse from the environment");
|
||||
assert_eq!(config.trusted_proxy_cidrs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_hardening_defaults_are_explicit() {
|
||||
let config = Config::try_parse_from(["ngit-grasp", "--domain", "example.com"])
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
use hyper::header::{HeaderMap, FORWARDED};
|
||||
use ipnet::IpNet;
|
||||
|
||||
const X_FORWARDED_FOR: &str = "x-forwarded-for";
|
||||
const X_REAL_IP: &str = "x-real-ip";
|
||||
|
||||
/// Resolve the address used for WebSocket accounting and relay connection
|
||||
/// policy without allowing arbitrary direct peers to spoof forwarding headers.
|
||||
pub(super) fn resolve_client_addr(
|
||||
peer: SocketAddr,
|
||||
headers: &HeaderMap,
|
||||
trusted_proxies: &[IpNet],
|
||||
) -> SocketAddr {
|
||||
if !is_trusted(peer.ip(), trusted_proxies) {
|
||||
return peer;
|
||||
}
|
||||
|
||||
// Prefer X-Forwarded-For because common reverse proxies (including Caddy)
|
||||
// append the TCP source to that chain while an inbound RFC Forwarded
|
||||
// header may otherwise pass through untouched.
|
||||
let client_ip = if headers.contains_key(X_FORWARDED_FOR) {
|
||||
parse_x_forwarded_for(headers)
|
||||
.map(|chain| resolve_chain(peer.ip(), &chain, trusted_proxies))
|
||||
} else if headers.contains_key(FORWARDED) {
|
||||
parse_forwarded(headers).map(|chain| resolve_chain(peer.ip(), &chain, trusted_proxies))
|
||||
} else if headers.contains_key(X_REAL_IP) {
|
||||
parse_x_real_ip(headers)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
client_ip
|
||||
.map(|ip| SocketAddr::new(ip, peer.port()))
|
||||
.unwrap_or(peer)
|
||||
}
|
||||
|
||||
/// Walk from the TCP peer towards the originating client. Each forwarding hop
|
||||
/// is consumed only while the current hop is trusted, so client-supplied
|
||||
/// entries to the left of the first untrusted address cannot take effect.
|
||||
fn resolve_chain(peer: IpAddr, forwarded: &[IpAddr], trusted_proxies: &[IpNet]) -> IpAddr {
|
||||
let mut current = peer;
|
||||
|
||||
for forwarded_ip in forwarded.iter().rev() {
|
||||
if !is_trusted(current, trusted_proxies) {
|
||||
break;
|
||||
}
|
||||
current = *forwarded_ip;
|
||||
}
|
||||
|
||||
current
|
||||
}
|
||||
|
||||
fn is_trusted(ip: IpAddr, trusted_proxies: &[IpNet]) -> bool {
|
||||
trusted_proxies.iter().any(|network| network.contains(&ip))
|
||||
}
|
||||
|
||||
fn parse_forwarded(headers: &HeaderMap) -> Option<Vec<IpAddr>> {
|
||||
let mut addresses = Vec::new();
|
||||
|
||||
for value in headers.get_all(FORWARDED) {
|
||||
let value = value.to_str().ok()?;
|
||||
for element in value.split(',') {
|
||||
let mut address = None;
|
||||
for parameter in element.split(';') {
|
||||
let (name, value) = parameter.trim().split_once('=')?;
|
||||
if name.trim().eq_ignore_ascii_case("for") {
|
||||
if address.is_some() {
|
||||
return None;
|
||||
}
|
||||
address = parse_forwarded_address(value.trim());
|
||||
address?;
|
||||
}
|
||||
}
|
||||
addresses.push(address?);
|
||||
}
|
||||
}
|
||||
|
||||
(!addresses.is_empty()).then_some(addresses)
|
||||
}
|
||||
|
||||
fn parse_forwarded_address(value: &str) -> Option<IpAddr> {
|
||||
let value = match (value.strip_prefix('"'), value.strip_suffix('"')) {
|
||||
(Some(_), None) | (None, Some(_)) => return None,
|
||||
(Some(without_prefix), Some(_)) => without_prefix.strip_suffix('"')?,
|
||||
(None, None) => value,
|
||||
};
|
||||
|
||||
if value.is_empty() || value.eq_ignore_ascii_case("unknown") || value.starts_with('_') {
|
||||
return None;
|
||||
}
|
||||
|
||||
value
|
||||
.parse::<IpAddr>()
|
||||
.ok()
|
||||
.or_else(|| value.parse::<SocketAddr>().ok().map(|addr| addr.ip()))
|
||||
.or_else(|| {
|
||||
value
|
||||
.strip_prefix('[')?
|
||||
.strip_suffix(']')?
|
||||
.parse::<IpAddr>()
|
||||
.ok()
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_x_forwarded_for(headers: &HeaderMap) -> Option<Vec<IpAddr>> {
|
||||
let mut addresses = Vec::new();
|
||||
|
||||
for value in headers.get_all(X_FORWARDED_FOR) {
|
||||
let value = value.to_str().ok()?;
|
||||
for address in value.split(',') {
|
||||
addresses.push(address.trim().parse().ok()?);
|
||||
}
|
||||
}
|
||||
|
||||
(!addresses.is_empty()).then_some(addresses)
|
||||
}
|
||||
|
||||
fn parse_x_real_ip(headers: &HeaderMap) -> Option<IpAddr> {
|
||||
let mut values = headers.get_all(X_REAL_IP).iter();
|
||||
let address = values.next()?.to_str().ok()?.trim().parse().ok()?;
|
||||
values.next().is_none().then_some(address)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hyper::header::{HeaderValue, FORWARDED};
|
||||
|
||||
fn address(value: &str) -> SocketAddr {
|
||||
value.parse().unwrap()
|
||||
}
|
||||
|
||||
fn networks(values: &[&str]) -> Vec<IpNet> {
|
||||
values.iter().map(|value| value.parse().unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_spoofed_headers_from_an_untrusted_peer() {
|
||||
let peer = address("198.51.100.20:4242");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(X_FORWARDED_FOR, HeaderValue::from_static("203.0.113.9"));
|
||||
|
||||
assert_eq!(
|
||||
resolve_client_addr(peer, &headers, &networks(&["127.0.0.1/32"])),
|
||||
peer
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_the_tcp_peer_without_forwarding_headers() {
|
||||
let peer = address("127.0.0.1:4242");
|
||||
|
||||
assert_eq!(
|
||||
resolve_client_addr(peer, &HeaderMap::new(), &networks(&["127.0.0.1/32"])),
|
||||
peer
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_a_client_behind_one_trusted_proxy() {
|
||||
let peer = address("127.0.0.1:4242");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(X_FORWARDED_FOR, HeaderValue::from_static("198.51.100.20"));
|
||||
|
||||
assert_eq!(
|
||||
resolve_client_addr(peer, &headers, &networks(&["127.0.0.1/32"])).ip(),
|
||||
"198.51.100.20".parse::<IpAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walks_a_chain_of_trusted_proxies_from_the_peer_inward() {
|
||||
let peer = address("10.0.0.3:4242");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
X_FORWARDED_FOR,
|
||||
HeaderValue::from_static("198.51.100.20, 10.0.0.2"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolve_client_addr(peer, &headers, &networks(&["10.0.0.0/24"])).ip(),
|
||||
"198.51.100.20".parse::<IpAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_before_client_supplied_entries_left_of_the_first_untrusted_hop() {
|
||||
let peer = address("10.0.0.3:4242");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
X_FORWARDED_FOR,
|
||||
HeaderValue::from_static("203.0.113.9, 198.51.100.20"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolve_client_addr(peer, &headers, &networks(&["10.0.0.0/24"])).ip(),
|
||||
"198.51.100.20".parse::<IpAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fails_closed_on_a_malformed_forwarding_chain() {
|
||||
let peer = address("127.0.0.1:4242");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
X_FORWARDED_FOR,
|
||||
HeaderValue::from_static("198.51.100.20, invalid"),
|
||||
);
|
||||
headers.insert(X_REAL_IP, HeaderValue::from_static("198.51.100.20"));
|
||||
|
||||
assert_eq!(
|
||||
resolve_client_addr(peer, &headers, &networks(&["127.0.0.1/32"])),
|
||||
peer
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_forwarded_ipv6_addresses_and_ports() {
|
||||
let peer = address("127.0.0.1:4242");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
FORWARDED,
|
||||
HeaderValue::from_static("for=\"[2001:db8::7]:4711\";proto=https"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolve_client_addr(peer, &headers, &networks(&["127.0.0.1/32"])).ip(),
|
||||
"2001:db8::7".parse::<IpAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_a_proxy_appended_x_forwarded_for_chain() {
|
||||
let peer = address("127.0.0.1:4242");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(X_FORWARDED_FOR, HeaderValue::from_static("198.51.100.20"));
|
||||
headers.insert(FORWARDED, HeaderValue::from_static("for=203.0.113.9"));
|
||||
|
||||
assert_eq!(
|
||||
resolve_client_addr(peer, &headers, &networks(&["127.0.0.1/32"])).ip(),
|
||||
"198.51.100.20".parse::<IpAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_x_real_ip_when_no_chain_header_is_present() {
|
||||
let peer = address("127.0.0.1:4242");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(X_REAL_IP, HeaderValue::from_static("198.51.100.20"));
|
||||
|
||||
assert_eq!(
|
||||
resolve_client_addr(peer, &headers, &networks(&["127.0.0.1/32"])).ip(),
|
||||
"198.51.100.20".parse::<IpAddr>().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
+32
-7
@@ -1,6 +1,7 @@
|
||||
/// HTTP Server Module
|
||||
///
|
||||
/// Provides hyper HTTP server with WebSocket upgrade support for the Nostr relay.
|
||||
//! HTTP Server Module
|
||||
//!
|
||||
//! Provides hyper HTTP server with WebSocket upgrade support for the Nostr relay.
|
||||
mod client_ip;
|
||||
pub mod landing;
|
||||
pub mod nip11;
|
||||
|
||||
@@ -722,14 +723,23 @@ impl Service<Request<Incoming>> for HttpService {
|
||||
let key = req.headers().get("sec-websocket-key");
|
||||
let derived = key.map(|k| derive_accept_key(k.as_bytes()));
|
||||
|
||||
let addr = self.remote;
|
||||
let peer = self.remote;
|
||||
let addr = client_ip::resolve_client_addr(
|
||||
peer,
|
||||
req.headers(),
|
||||
&self.config.trusted_proxy_cidrs,
|
||||
);
|
||||
let relay = self.relay.clone();
|
||||
let metrics_clone = self.metrics.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
match hyper::upgrade::on(req).await {
|
||||
Ok(upgraded) => {
|
||||
tracing::info!("WebSocket connection established from {}", addr);
|
||||
tracing::info!(
|
||||
client_ip = %addr.ip(),
|
||||
peer = %peer,
|
||||
"WebSocket connection established"
|
||||
);
|
||||
// Track connection
|
||||
let _connection_timer =
|
||||
metrics_clone.as_ref().map(|m| m.start_connection_timer());
|
||||
@@ -740,9 +750,18 @@ impl Service<Request<Incoming>> for HttpService {
|
||||
if let Err(e) =
|
||||
relay.take_connection(TokioIo::new(upgraded), addr).await
|
||||
{
|
||||
tracing::error!("Relay error for {}: {}", addr, e);
|
||||
tracing::error!(
|
||||
client_ip = %addr.ip(),
|
||||
peer = %peer,
|
||||
error = %e,
|
||||
"Relay connection failed"
|
||||
);
|
||||
}
|
||||
tracing::info!("WebSocket connection closed for {}", addr);
|
||||
tracing::info!(
|
||||
client_ip = %addr.ip(),
|
||||
peer = %peer,
|
||||
"WebSocket connection closed"
|
||||
);
|
||||
// Untrack connection
|
||||
if let Some(ref m) = metrics_clone {
|
||||
m.connection_tracker().on_disconnect(addr.ip());
|
||||
@@ -907,6 +926,12 @@ pub async fn run_server_on_listener(
|
||||
tracing::info!("Starting HTTP server on {}", listener.local_addr()?);
|
||||
tracing::info!("Relay name: {}", config.relay_name());
|
||||
tracing::info!("Domain: {}", config.domain);
|
||||
if !config.trusted_proxy_cidrs.is_empty() {
|
||||
tracing::info!(
|
||||
trusted_proxy_cidrs = ?config.trusted_proxy_cidrs,
|
||||
"Trusted proxy client IP resolution enabled"
|
||||
);
|
||||
}
|
||||
|
||||
loop {
|
||||
let (socket, addr) = listener.accept().await?;
|
||||
|
||||
Reference in New Issue
Block a user