Every announcement must carry a valid, recent MAC or it is rejected. If the
secrets on the proxy and a backend differ, that backend's announcements are
silently rejected (and logged), which appears as the backend never joining
the balancer.
- If no secret is configured the channel is unauthenticated and the proxy
- emits a warning when it starts listening. Because the secret is stored in
- the configuration file, restrict that file's permissions as you would for a
- private key.
+ Because the secret is stored in the configuration file, restrict that
+ file's permissions as you would for a private key.
Clock synchronisation
The timestamp-based replay protection compares the announcement's time
diff --git a/modules/proxy/README.beacon b/modules/proxy/README.beacon
index 143befd1b7..adf60bf1b6 100644
--- a/modules/proxy/README.beacon
+++ b/modules/proxy/README.beacon
@@ -93,19 +93,21 @@ Message format:
BEACON url=http://host:port host= pid= seq= ts= mac=
url= is the routable backend origin the proxy adds as a BalancerMember. ts=
- (microseconds since the epoch) and mac= are present only when a shared secret
- is configured (see below). host=/pid=/seq= are informational.
+ (microseconds since the epoch) and mac= authenticate the message and are always
+ present, since a shared secret is required (see below). host=/pid=/seq= are
+ informational.
A UDP datagram is delivered whole, so the receiver reads each message into a
fixed stack buffer and NUL-terminates it (buf[len] = '\0') before parsing.
The sender transmits strlen(msg) bytes (no trailing NUL on the wire).
-Authentication (ProxyBeaconSecret, optional but recommended):
- Without a secret the channel is unauthenticated: anyone who can reach the
- receiver port could announce a URL and hijack client traffic (the proxy logs a
- warning at startup in this case). A UDP source address is trivially spoofable,
- so authentication matters at least as much here as it would over a connection.
+Authentication (ProxyBeaconSecret, REQUIRED):
+ ProxyBeaconSecret must be set on every participating server (the receiver and
+ every sender); startup fails otherwise. There is no unauthenticated mode:
+ without a MAC, anyone who can reach the receiver port could announce a URL and
+ hijack client traffic, and a UDP source address is trivially spoofable, so
+ authentication matters at least as much here as it would over a connection.
With ProxyBeaconSecret set identically on the proxy and all backends, each
announcement is signed with a SipHash-2-4 MAC over the message prefix (the key
diff --git a/modules/proxy/mod_proxy_beacon-guide.md b/modules/proxy/mod_proxy_beacon-guide.md
index 930fb0000e..4495dd0cc2 100644
--- a/modules/proxy/mod_proxy_beacon-guide.md
+++ b/modules/proxy/mod_proxy_beacon-guide.md
@@ -124,7 +124,7 @@ as an enabled member of `balancer://cluster` on the proxy. Stop one, and after
| `ProxyBeaconListen [addr][:port]` | â | **Marks this server as the receiver.** Binds a UDP socket. `addr`/`port` are optional and inherited from the server's own `Listen`/`ServerName` when omitted (see note below). |
| `ProxyBeaconBalancer name` | â | Balancer that announced backends are added to. Bare name (`cluster`); a leading `balancer://` is stripped. Must already exist with spare `growth`. |
| `ProxyBeaconTimeout interval` | `0` (no eviction) | Seconds of silence before a member is disabled. `0` = add-only, never auto-remove. Set to a small multiple of the backends' interval to get self-healing. |
-| `ProxyBeaconSecret secret` | â (unauthenticated) | Shared cluster secret; **same value on proxy and all backends.** |
+| `ProxyBeaconSecret secret` | â (**required**) | Shared cluster secret; **same value on proxy and all backends.** The server fails to start if a participating server omits it. |
| `ProxyBeaconMaxSkew interval` | `30` | Anti-replay freshness window: reject announcements whose signed timestamp is more than this far from now (either direction). |
**Inheriting the listen address:** because UDP and TCP are separate port spaces,
@@ -141,7 +141,7 @@ this way â use an explicit high port there.)
| `ProxyBeaconAddress addr:port` | â | **Marks this server as a sender.** UDP target = the proxy's `ProxyBeaconListen` address. |
| `ProxyBeaconAdvertise url` | â | The routable `scheme://host[:port]` the proxy adds as a member. Omit it and the backend beacons but advertises nothing (logged, never added). |
| `ProxyBeaconInterval interval` | `5` | How often this backend announces. Must be **meaningfully smaller** than the proxy's `ProxyBeaconTimeout`. |
-| `ProxyBeaconSecret secret` | â | Same shared secret as the proxy. |
+| `ProxyBeaconSecret secret` | â (**required**) | Same shared secret as the proxy. |
> `ProxyBeaconListen` and `ProxyBeaconAddress` are **mutually exclusive** on the
> same server â a server is either a receiver or a sender, not both.
@@ -160,8 +160,9 @@ unauthenticated channel is a route-hijack / SSRF risk: anyone who can reach the
receive port could announce an arbitrary backend URL, and **UDP source addresses
are trivially spoofable.**
-**Always set `ProxyBeaconSecret`** in production (identical on proxy and every
-backend). With it:
+`ProxyBeaconSecret` is therefore **required** (identical on proxy and every
+backend) â the server refuses to start if a participating server omits it, so
+there is no unauthenticated mode to fall into. With it:
- Each announcement is signed with a **SipHash-2-4 MAC** plus a timestamp; the
proxy recomputes the MAC (constant-time compare) and drops anything forged or
@@ -177,8 +178,6 @@ Operational notes:
- **Clocks must be roughly in sync** (NTP). The timestamp check compares the
announcement's time against the proxy's clock; widen `ProxyBeaconMaxSkew` if
your hosts drift.
-- With **no secret**, the channel is unauthenticated and the proxy logs a
- one-time `UNAUTHENTICATED` warning at startup.
- The secret lives in your config file â **restrict its permissions like a
private key.**
- Announcements are **authenticated, not encrypted.** The payload is operational
@@ -233,7 +232,7 @@ Useful log signals (grep the error log):
| `re-enabled backend ...` | a previously-evicted backend came back |
| `dropped ... mac mismatch` | wrong/missing secret on a sender (or a forged datagram) |
| `replayed/reordered ts` | a stale/duplicate datagram was rejected |
-| `UNAUTHENTICATED` (startup) | no `ProxyBeaconSecret` â channel is open |
+| `ProxyBeaconSecret is required` (startup) | a participating server has no `ProxyBeaconSecret` â startup aborts |
**Common gotchas:**
diff --git a/modules/proxy/mod_proxy_beacon.c b/modules/proxy/mod_proxy_beacon.c
index 3cbbd0dd35..6adb468aa5 100644
--- a/modules/proxy/mod_proxy_beacon.c
+++ b/modules/proxy/mod_proxy_beacon.c
@@ -72,8 +72,9 @@
* a keyed MAC (SipHash-2-4 via APR-util -- the same primitive mod_session_crypto
* uses) plus a timestamp; the receiver recomputes the MAC and checks timestamp
* freshness (anti-replay), dropping any forged, tampered, or stale message
- * before it is parsed/acted on. Authentication is opt-in: with no secret the
- * channel behaves as before but logs a one-time "UNAUTHENTICATED" warning. We
+ * before it is parsed/acted on. Authentication is REQUIRED: ProxyBeaconSecret
+ * must be set on every server that participates in the channel (sender or
+ * listener), or startup fails -- there is no unauthenticated mode. We
* authenticate, not encrypt -- the payload (backend URLs) is not secret; for
* transport confidentiality DTLS would be a separate, orthogonal future layer.
*
@@ -150,7 +151,6 @@ typedef struct {
apr_int64_t max_skew; /* ProxyBeaconMaxSkew, seconds (replay window) */
apr_uint64_t reject_count; /* listener: dropped-since-last-log counter */
apr_time_t last_reject_log; /* listener: reject-log rate limiter */
- int warned_insecure; /* listener: emitted the one-time warning */
int open_failed; /* STARTING: socket open/listen/dial failed permanently */
} beacon_ctx_t;
@@ -166,9 +166,9 @@ typedef struct {
* announcement -- back off this long between attempts for a given url. */
#define BEACON_RETRY_BACKOFF apr_time_from_sec(60)
-/* Cap on tracked backend urls, to bound memory against an unauthenticated
- * channel announcing unboundedly many distinct urls. Once reached, unknown
- * urls are dropped (rate-limited log) rather than tracked/added. */
+/* Cap on tracked backend urls, to bound memory against a buggy or compromised
+ * (authenticated) backend announcing unboundedly many distinct urls. Once
+ * reached, unknown urls are dropped (rate-limited log) rather than tracked/added. */
#define BEACON_MAX_MEMBERS 256
/* Process-wide watchdog handle, like mod_proxy_hcheck's static watchdog. */
@@ -383,9 +383,10 @@ static const command_rec beacon_cmds[] = {
"proxy: seconds without an announcement after which a backend "
"is disabled (taken out of rotation); 0 disables eviction"),
AP_INIT_TAKE1("ProxyBeaconSecret", beacon_set_secret, NULL, RSRC_CONF,
- "pre-shared cluster secret; set on both proxy and backends to "
- "authenticate announcements (SipHash MAC). Keep the conf file "
- "readable only by the server user."),
+ "pre-shared cluster secret (REQUIRED); set to the same value on "
+ "the proxy and all backends to authenticate announcements "
+ "(SipHash MAC). Keep the conf file readable only by the server "
+ "user."),
AP_INIT_TAKE1("ProxyBeaconMaxSkew", beacon_set_maxskew, NULL, RSRC_CONF,
"proxy: max allowed seconds between an announcement's timestamp "
"and now (anti-replay window; requires NTP-synced clocks). "
@@ -544,15 +545,6 @@ static void beacon_cb_starting(beacon_ctx_t *ctx)
"Set ProxyBeaconBalancer to add announced backends.",
ctx->addr);
}
- /* Phase 4: warn once if we'll add members from an unauthenticated
- * channel (anyone who can reach this port could announce a backend). */
- if (ctx->balancer_name && !ctx->has_secret && !ctx->warned_insecure) {
- ap_log_error(APLOG_MARK, APLOG_WARNING, 0, s,
- APLOGNO(10572) "mod_proxy_beacon: beacon channel on %pI is "
- "UNAUTHENTICATED; set ProxyBeaconSecret on the proxy and "
- "all backends to require signed beacons", ctx->addr);
- ctx->warned_insecure = 1;
- }
}
else if (ctx->role == BEACON_ROLE_SEND) {
/* Sender: the destination host:port targets the proxy and cannot be
@@ -763,8 +755,8 @@ static apr_status_t beacon_try_add(beacon_ctx_t *ctx, apr_pool_t *pool,
* - a previously-evicted member is re-enabled when it announces again;
* - an add that failed (e.g. balancer full) is retried only after a backoff,
* not on every announcement.
- * msg_ts is the signed timestamp (microseconds) from the verified message, or 0
- * when the channel is unauthenticated.
+ * msg_ts is the signed timestamp (microseconds) from the verified message; this
+ * path is only reached on the authenticated balancer path, so it is always set.
*/
static void beacon_handle_announce(beacon_ctx_t *ctx, apr_pool_t *pool,
const char *url, apr_time_t now,
@@ -773,8 +765,8 @@ static void beacon_handle_announce(beacon_ctx_t *ctx, apr_pool_t *pool,
beacon_member_t *m = apr_hash_get(ctx->seen, url, APR_HASH_KEY_STRING);
if (!m) {
- /* Bound memory: don't track unboundedly many distinct urls (a concern
- * on an unauthenticated channel). */
+ /* Bound memory: don't track unboundedly many distinct urls (defense in
+ * depth against a buggy or compromised authenticated backend). */
if (apr_hash_count(ctx->seen) >= BEACON_MAX_MEMBERS) {
beacon_log_throttled(ctx, now, "member table full");
return;
@@ -788,12 +780,12 @@ static void beacon_handle_announce(beacon_ctx_t *ctx, apr_pool_t *pool,
return;
}
- /* Anti-replay (2): on an authenticated channel, each url's signed ts must
- * strictly increase. A replayed (byte-identical) announcement carries a ts
- * we've already accepted, so reject it -- this closes the in-window replay
- * the freshness check alone allows (e.g. replaying a dead backend's last
- * announcement to keep it from being evicted). */
- if (ctx->has_secret && msg_ts <= m->last_ts) {
+ /* Anti-replay (2): each url's signed ts must strictly increase. A replayed
+ * (byte-identical) announcement carries a ts we've already accepted, so
+ * reject it -- this closes the in-window replay the freshness check alone
+ * allows (e.g. replaying a dead backend's last announcement to keep it from
+ * being evicted). */
+ if (msg_ts <= m->last_ts) {
beacon_log_throttled(ctx, now, "replayed/reordered ts");
return;
}
@@ -912,14 +904,13 @@ static void beacon_mac_hex(const beacon_ctx_t *ctx, const char *base,
ap_bin2hex(mac, sizeof(mac), out); /* writes BEACON_MAC_HEXLEN + NUL */
}
-/* sender: return " mac=" (signed) when a secret is set, else base. */
+/* sender: return " mac=", appending the keyed MAC. A secret is
+ * required on every participating server (enforced at config time), so this
+ * always signs. */
static const char *beacon_sign(beacon_ctx_t *ctx, apr_pool_t *pool, const char *base)
{
char hex[BEACON_MAC_HEXLEN + 1];
- if (!ctx->has_secret) {
- return base;
- }
beacon_mac_hex(ctx, base, strlen(base), hex);
return apr_psprintf(pool, "%s mac=%s", base, hex);
}
@@ -1087,20 +1078,22 @@ static void beacon_cb_running(beacon_ctx_t *ctx, apr_pool_t *pool)
buf[sz] = '\0';
msg_str = buf;
- /* Escape the untrusted payload before it can reach the log: a
- * publisher -- or, on an unauthenticated channel, anyone who can
- * reach the listen port -- could embed newline/control/ANSI
- * sequences to forge or corrupt error-log lines. */
+ /* Escape the untrusted payload before it can reach the log: on the
+ * log-only path (no ProxyBeaconBalancer) messages are logged without
+ * MAC verification, so anyone who can reach the listen port could
+ * embed newline/control/ANSI sequences to forge or corrupt error-log
+ * lines. (On the balancer path, forged messages are dropped at
+ * verification below and `safe` is only logged after it passes.) */
safe = ap_escape_logitem(pool, msg_str);
- /* Phase 4: when a secret is set, verify the MAC and freshness
- * BEFORE logging or acting on the payload, so forged/tampered
- * content is dropped (throttled) and never reaches the INFO log.
+ /* Phase 4: verify the MAC and freshness BEFORE logging or acting on
+ * the payload, so forged/tampered content is dropped (throttled) and
+ * never reaches the INFO log. A secret is required (enforced at
+ * config time), so this always runs in the active (balancer) path.
* msg_ts (microseconds) is the signed timestamp, used below for the
- * per-url monotonic replay check. Verification only matters in the
- * active (balancer) path; with no balancer we take no action and
- * the escaped payload is safe to log for diagnostics. */
- if (ctx->balancer_name && ctx->has_secret) {
+ * per-url monotonic replay check. With no balancer we take no action
+ * and the escaped payload is safe to log for diagnostics. */
+ if (ctx->balancer_name) {
const char *reason = "?";
if (!beacon_verify(ctx, msg_str, msg_now, &msg_ts, &reason)) {
beacon_log_throttled(ctx, msg_now, reason);
@@ -1210,6 +1203,20 @@ static int beacon_post_config(apr_pool_t *pconf, apr_pool_t *plog,
if (ctx->role == BEACON_ROLE_NONE) {
continue;
}
+ /* ProxyBeaconSecret is required on every participating server (sender or
+ * listener): the channel is authenticated, with no unauthenticated mode.
+ * A UDP source address is trivially spoofable, so an unsigned channel
+ * would let anyone who can reach the listen port announce an arbitrary
+ * backend url and hijack client traffic. Fail startup rather than run
+ * insecurely. */
+ if (!ctx->has_secret) {
+ ap_log_error(APLOG_MARK, APLOG_CRIT, 0, s,
+ APLOGNO(10572) "mod_proxy_beacon: ProxyBeaconSecret is "
+ "required but not set; the beacon channel must be "
+ "authenticated. Set ProxyBeaconSecret to the same value "
+ "on the proxy and all backends.");
+ return !OK;
+ }
rv = beacon_register_callback(beacon_watchdog, AP_WD_TM_SLICE, ctx,
beacon_watchdog_callback);
if (rv) {
--
2.47.3