Believe X-Forwarded-For only from FN_TRUSTED_PROXY_ADDRS
clientIP() now checks the TCP peer first. When the peer is in FN_TRUSTED_PROXY_ADDRS (addresses or CIDR ranges, IPv4 or IPv6), it reads the header as before, FN_TRUSTED_PROXIES hops from the right. From any other peer it ignores the header and returns the peer's own address. Unset trusts nobody, so a relay exposed directly cannot be handed a false address. Behind a proxy it meters everyone as the proxy until the variable is set. An entry that does not parse matches nothing. On our box the relay shares the web network with 43 other containers, each able to connect past Traefik, geogate and CrowdSec. Production needs Traefik's web address pinned and listed here before this deploys. The onion path is unchanged: gateway -> Traefik -> relay, peer Traefik. CONFIGURATION, HOSTING, SECURITY and CONTRIBUTING say so; the generated pages are rebuilt; docker-compose.prod.yml passes the variable through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
aa7722195d
commit
ef814ae175
+4
-3
@@ -154,9 +154,10 @@ belongs there, and CI fails if the file is missing.
|
||||
all, **fatal-error if touched before `didBoot`**. That mistake crash-looped
|
||||
production for real. New startup work goes in the existing `BootTask`
|
||||
lifecycle handler, not at file scope and not in `configure`.
|
||||
- **Touching rate limiting?** Per-IP limits count `X-Forwarded-For` from the
|
||||
*right*, bounded by `FN_TRUSTED_PROXIES`. Counting from the left lets a client
|
||||
choose its own bucket. Sign-in and quota limiters fail *closed* to a local
|
||||
- **Touching rate limiting?** Per-IP limits read `X-Forwarded-For` only when
|
||||
the TCP peer is in `FN_TRUSTED_PROXY_ADDRS`, and count from the *right*,
|
||||
bounded by `FN_TRUSTED_PROXIES`. Counting from the left, or believing any
|
||||
peer, lets a client choose its own bucket. Sign-in and quota limiters fail *closed* to a local
|
||||
fallback when Redis is unreachable; keep it that way.
|
||||
- Adding a claim to a public page or to `/metadata`? It has to be true of the
|
||||
running configuration, not of the code in the abstract. `/metadata` renders
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/CONFIGURATION.md.
|
||||
Edits made HERE are deleted the next time that script runs.
|
||||
Change the markdown source and re-run it.
|
||||
source-sha256: 9d8c3fe0b9db1b82 -->
|
||||
source-sha256: 272724e75c56bd0b -->
|
||||
<article class="band doc doc-generated">
|
||||
<div class="wrap wrap-narrow">
|
||||
<h1 id="configuring-a-relay">Configuring a relay</h1>
|
||||
@@ -891,16 +891,30 @@ Xcode.</td>
|
||||
<tr>
|
||||
<td><code>FN_TRUSTED_PROXIES</code></td>
|
||||
<td><code>1</code></td>
|
||||
<td>How many proxy hops to trust when reading
|
||||
<code>X-Forwarded-For</code>. The default, 1, takes the last entry in
|
||||
the header, which is right behind exactly one reverse proxy that
|
||||
replaces the header rather than appending to what the client sent
|
||||
(Traefik does, with no <code>forwardedHeaders</code> trust configured).
|
||||
A relay exposed directly, with nothing in front, must set 0: left at 1
|
||||
it believes whatever a client writes last in the header, and every
|
||||
per-IP limit can be dodged. At 0 the relay uses the socket address.
|
||||
Never set it higher than the number of proxies you run: a client can
|
||||
forge the header for any hop you trust beyond them.</td>
|
||||
<td>How many proxy hops to trust in <code>X-Forwarded-For</code>,
|
||||
counted from the right, when the request comes from a trusted proxy
|
||||
(<code>FN_TRUSTED_PROXY_ADDRS</code>). 1 takes the last entry, right
|
||||
behind one proxy that replaces the header rather than appending to what
|
||||
the client sent (Traefik does, with no <code>forwardedHeaders</code>
|
||||
trust configured). Never set it higher than the number of proxies you
|
||||
run: a client can forge the header for any hop trusted beyond them. 0
|
||||
ignores the header even from a trusted proxy.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FN_TRUSTED_PROXY_ADDRS</code></td>
|
||||
<td>unset</td>
|
||||
<td>The proxies whose <code>X-Forwarded-For</code> the relay reads:
|
||||
comma-separated addresses or CIDR ranges, IPv4 or IPv6
|
||||
(<code>172.18.255.241</code>, <code>10.0.0.0/24</code>). The relay
|
||||
compares each request's TCP peer against it. From any other peer the
|
||||
header is ignored and the peer's own address keys every per-IP limit.
|
||||
Unset, nobody is trusted: a relay exposed directly cannot be given a
|
||||
false address, and a relay behind a proxy meters everyone as the proxy
|
||||
until this is set. List your edge proxy's fixed address and nothing
|
||||
wider: a container sharing the proxy's network can reach the relay
|
||||
directly and write any header it likes. Pin that address in the proxy's
|
||||
own compose, or a restart can hand it to another container. An entry
|
||||
that does not parse matches nothing.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/HOSTING.md.
|
||||
Edits made HERE are deleted the next time that script runs.
|
||||
Change the markdown source and re-run it.
|
||||
source-sha256: 98d7d82f48b654db -->
|
||||
source-sha256: da3efb24c74a2759 -->
|
||||
<article class="band doc doc-generated">
|
||||
<div class="wrap wrap-narrow">
|
||||
<h1 id="running-your-own-relay">Running your own relay</h1>
|
||||
@@ -40,9 +40,11 @@ Tokens carry it and the relay checks it, so a mismatch rejects
|
||||
several names still has exactly one of these.</p>
|
||||
<p>Terminate TLS in front of it and forward the client address. Clients
|
||||
hold key material, and plain HTTP hands it to anyone on the path. Sealed
|
||||
submissions are metered per recipient and per IP; behind a proxy that
|
||||
does not set <code>X-Forwarded-For</code>, every request looks like the
|
||||
proxy and that limit is theatre.</p>
|
||||
submissions are metered per recipient and per IP. The relay reads
|
||||
<code>X-Forwarded-For</code> only from the addresses in
|
||||
<code>FN_TRUSTED_PROXY_ADDRS</code>, so pin your proxy's address and put
|
||||
it there. Until you do, every request is metered as the proxy, and one
|
||||
busy hour of sign-ups from anyone uses up everyone's limit.</p>
|
||||
<p>Every variable is in <a href="/configuration">Configuration</a>.</p>
|
||||
<hr />
|
||||
<h2 id="voice-and-video-calls">Voice and video calls</h2>
|
||||
|
||||
+5
-3
@@ -37,9 +37,11 @@ long, rendered from that instance's own configuration.
|
||||
|
||||
## Operator responsibilities
|
||||
|
||||
- Put it behind a reverse proxy and set `FN_TRUSTED_PROXIES` to the number of
|
||||
hops you control. Per-IP limits count from the right of `X-Forwarded-For`; a
|
||||
wrong hop count makes sign-in throttling weaker than it looks.
|
||||
- Put it behind a reverse proxy, pin the proxy's address, and set
|
||||
`FN_TRUSTED_PROXY_ADDRS` to that address. The relay reads `X-Forwarded-For`
|
||||
only from those peers, counting `FN_TRUSTED_PROXIES` hops from the right.
|
||||
Unset, every per-IP limit is keyed on the proxy's address, so sign-in
|
||||
throttling and sign-up limits apply to everyone at once.
|
||||
- Keep `FN_ISSUER_PRIVATE_KEY` and `FN_AT_REST_SECRET` out of the image and off
|
||||
shared disks. A leaked issuer key lets someone mint tokens for any account.
|
||||
- Raise the file-descriptor limit (`ulimits: nofile: 65536`). Each held
|
||||
|
||||
@@ -1590,7 +1590,13 @@ func body(of request: Request) -> Data {
|
||||
|
||||
/// The address to meter per-IP limits by, sign-up throttle, sign-in lockout,
|
||||
/// sealed-submission metering.
|
||||
///
|
||||
/// X-Forwarded-For is read only when the TCP peer is a trusted proxy
|
||||
/// (`FN_TRUSTED_PROXY_ADDRS`, see `TrustedProxies`). From any other peer the
|
||||
/// header is ignored and the peer's own address is used.
|
||||
func clientIP(_ request: Request) -> String {
|
||||
let peer = request.remoteAddress?.ipAddress
|
||||
guard let peer, TrustedProxies.current.trusts(peer) else { return peer ?? "unknown" }
|
||||
let hops = Int(ProcessInfo.processInfo.environment["FN_TRUSTED_PROXIES"] ?? "") ?? 1
|
||||
if hops > 0, let forwarded = request.headers.first(name: .xForwardedFor) {
|
||||
let chain = forwarded.split(separator: ",")
|
||||
@@ -1604,7 +1610,7 @@ func clientIP(_ request: Request) -> String {
|
||||
// A chain shorter than the trusted-hop count means the request did not
|
||||
// arrive through the proxies we think are in front of us.
|
||||
}
|
||||
return request.remoteAddress?.ipAddress ?? "unknown"
|
||||
return peer
|
||||
}
|
||||
|
||||
/// Renders `WireError` as the JSON body clients already parse.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
import NIOConcurrencyHelpers
|
||||
|
||||
/// The peers whose X-Forwarded-For the relay believes: `FN_TRUSTED_PROXY_ADDRS`,
|
||||
/// a comma-separated list of addresses and CIDR ranges, IPv4 or IPv6.
|
||||
///
|
||||
/// Unset or empty, nobody is trusted and every per-IP limit is keyed on the
|
||||
/// TCP peer. That is the safe answer for a relay that does not know what is in
|
||||
/// front of it: behind a proxy it meters everyone as the proxy, which is
|
||||
/// strict, and exposed directly it cannot be talked into a different address.
|
||||
///
|
||||
/// On the production box the relay shares the `web` network with every other
|
||||
/// container. Any of them can connect to it directly, past Traefik, geogate and
|
||||
/// CrowdSec, and write whatever header it likes. Only Traefik's pinned address
|
||||
/// goes in this list.
|
||||
///
|
||||
/// An entry that does not parse matches nothing. A typo narrows trust; it
|
||||
/// never widens it.
|
||||
struct TrustedProxies: Sendable {
|
||||
struct Range: Sendable, Equatable {
|
||||
let bytes: [UInt8]
|
||||
let prefix: Int
|
||||
|
||||
func contains(_ address: [UInt8]) -> Bool {
|
||||
guard address.count == bytes.count else { return false }
|
||||
var bits = prefix
|
||||
for i in 0..<bytes.count where bits > 0 {
|
||||
let take = min(bits, 8)
|
||||
let mask = UInt8(truncatingIfNeeded: 0xFF << (8 - take))
|
||||
if address[i] & mask != bytes[i] & mask { return false }
|
||||
bits -= take
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
let ranges: [Range]
|
||||
|
||||
init(_ raw: String) {
|
||||
ranges = raw.split(separator: ",").compactMap { Self.range(String($0)) }
|
||||
}
|
||||
|
||||
func trusts(_ peer: String) -> Bool {
|
||||
guard let address = Self.bytes(of: peer) else { return false }
|
||||
return ranges.contains { $0.contains(address) }
|
||||
}
|
||||
|
||||
static func range(_ entry: String) -> Range? {
|
||||
let text = entry.trimmingCharacters(in: .whitespaces)
|
||||
let parts = text.split(separator: "/", omittingEmptySubsequences: false)
|
||||
guard parts.count == 1 || parts.count == 2,
|
||||
let address = bytes(of: String(parts[0]))
|
||||
else { return nil }
|
||||
let width = address.count * 8
|
||||
var prefix = width
|
||||
if parts.count == 2 {
|
||||
guard let p = Int(parts[1]), parts[1].allSatisfy(\.isNumber), (0...width).contains(p)
|
||||
else { return nil }
|
||||
prefix = p
|
||||
}
|
||||
return Range(bytes: address, prefix: prefix)
|
||||
}
|
||||
|
||||
/// 4 bytes for IPv4, 16 for IPv6. An IPv4-mapped IPv6 address
|
||||
/// (`::ffff:172.18.0.25`) reads as its IPv4 form, so either spelling of a
|
||||
/// peer matches an IPv4 entry.
|
||||
static func bytes(of text: String) -> [UInt8]? {
|
||||
var v4 = in_addr()
|
||||
if inet_pton(AF_INET, text, &v4) == 1 {
|
||||
return withUnsafeBytes(of: &v4) { Array($0) }
|
||||
}
|
||||
var v6 = in6_addr()
|
||||
guard inet_pton(AF_INET6, text, &v6) == 1 else { return nil }
|
||||
let raw = withUnsafeBytes(of: &v6) { Array($0) }
|
||||
if raw[0..<10].allSatisfy({ $0 == 0 }), raw[10] == 0xFF, raw[11] == 0xFF {
|
||||
return Array(raw[12..<16])
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/// Parsed once per distinct value. The variable is read on every request
|
||||
/// so tests can set it, and parsing a list per request is wasted work.
|
||||
private static let cache = NIOLockedValueBox<(raw: String, parsed: TrustedProxies)?>(nil)
|
||||
|
||||
static var current: TrustedProxies {
|
||||
let raw = ProcessInfo.processInfo.environment["FN_TRUSTED_PROXY_ADDRS"] ?? ""
|
||||
return cache.withLockedValue { cached in
|
||||
if let cached, cached.raw == raw { return cached.parsed }
|
||||
let parsed = TrustedProxies(raw)
|
||||
cached = (raw, parsed)
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,12 @@ services:
|
||||
# that an IP is not a person, and the old default of 5 locked out the
|
||||
# sixth user behind any shared connection.
|
||||
FN_SIGNUP_MAX_PER_HOUR: "${FN_SIGNUP_MAX_PER_HOUR:-50}"
|
||||
# The edge proxy whose X-Forwarded-For the relay believes: its pinned
|
||||
# address on `web`, and nothing wider. Every container on `web` can reach
|
||||
# the relay directly and write the header. Empty, the relay meters every
|
||||
# request by the proxy's own address, so per-IP limits hit everyone at
|
||||
# once. See docs/CONFIGURATION.md.
|
||||
FN_TRUSTED_PROXY_ADDRS: "${FN_TRUSTED_PROXY_ADDRS:-}"
|
||||
# Empty means ON: a collected envelope is deleted when the app's next
|
||||
# poll confirms it, instead of sitting for FN_DELIVERED_RETENTION_DAYS.
|
||||
# Set 0 to turn it off. Postgres only whatever this says: SQLite reuses
|
||||
|
||||
@@ -276,7 +276,8 @@ together, and the relay logs that push is disabled if any is missing.
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|---|---|---|
|
||||
| `FN_TRUSTED_PROXIES` | `1` | How many proxy hops to trust when reading `X-Forwarded-For`. The default, 1, takes the last entry in the header, which is right behind exactly one reverse proxy that replaces the header rather than appending to what the client sent (Traefik does, with no `forwardedHeaders` trust configured). A relay exposed directly, with nothing in front, must set 0: left at 1 it believes whatever a client writes last in the header, and every per-IP limit can be dodged. At 0 the relay uses the socket address. Never set it higher than the number of proxies you run: a client can forge the header for any hop you trust beyond them. |
|
||||
| `FN_TRUSTED_PROXIES` | `1` | How many proxy hops to trust in `X-Forwarded-For`, counted from the right, when the request comes from a trusted proxy (`FN_TRUSTED_PROXY_ADDRS`). 1 takes the last entry, right behind one proxy that replaces the header rather than appending to what the client sent (Traefik does, with no `forwardedHeaders` trust configured). Never set it higher than the number of proxies you run: a client can forge the header for any hop trusted beyond them. 0 ignores the header even from a trusted proxy. |
|
||||
| `FN_TRUSTED_PROXY_ADDRS` | unset | The proxies whose `X-Forwarded-For` the relay reads: comma-separated addresses or CIDR ranges, IPv4 or IPv6 (`172.18.255.241`, `10.0.0.0/24`). The relay compares each request's TCP peer against it. From any other peer the header is ignored and the peer's own address keys every per-IP limit. Unset, nobody is trusted: a relay exposed directly cannot be given a false address, and a relay behind a proxy meters everyone as the proxy until this is set. List your edge proxy's fixed address and nothing wider: a container sharing the proxy's network can reach the relay directly and write any header it likes. Pin that address in the proxy's own compose, or a restart can hand it to another container. An entry that does not parse matches nothing. |
|
||||
|
||||
## Pluggable transport (obfs4)
|
||||
|
||||
|
||||
+4
-2
@@ -42,8 +42,10 @@ symptom. A relay reachable at several names still has exactly one of these.
|
||||
|
||||
Terminate TLS in front of it and forward the client address. Clients hold
|
||||
key material, and plain HTTP hands it to anyone on the path. Sealed submissions
|
||||
are metered per recipient and per IP; behind a proxy that does not set
|
||||
`X-Forwarded-For`, every request looks like the proxy and that limit is theatre.
|
||||
are metered per recipient and per IP. The relay reads `X-Forwarded-For` only from
|
||||
the addresses in `FN_TRUSTED_PROXY_ADDRS`, so pin your proxy's address and put it
|
||||
there. Until you do, every request is metered as the proxy, and one busy hour of
|
||||
sign-ups from anyone uses up everyone's limit.
|
||||
|
||||
Every variable is in [Configuration](/configuration).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user