refactor(geode): drop unused KtorRelay knobs (maxFrameBytes, maxAdminBodyBytes)

Ktor's WebSocket layer is unbounded by default, so [limits].max_ws_frame_bytes
was a strfry-parity tuning knob nobody actually used — drop the whole
LimitsSection from StaticConfig and the matching constructor param from
KtorRelay. The negentropy-large-corpus plan note is updated accordingly.

maxAdminBodyBytes is a real defense-in-depth cap (NIP-98 forces us to
read the body for the payload sha256 before we can authenticate, so an
unbounded read is a pre-auth DoS vector). Keep the cap, but stop
exposing it as an operator knob — it was never plumbed through to
StaticConfig and 1 MiB is ~1000× any plausible NIP-86 RPC payload.
Hardcoded at the Nip86HttpRoute construction site with the rationale
inline. Nip86HttpRoute.maxBodyBytes stays so tests can drive the 413
boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-12 16:14:15 -04:00
parent 47fdd8638e
commit f4bcf38caf
6 changed files with 21 additions and 55 deletions
-7
View File
@@ -66,13 +66,6 @@ require_auth = false
# the future. Enforced by RejectFutureEventsPolicy.
# reject_future_seconds = 1800
[limits]
# Maximum WebSocket frame size. Frames larger than this are dropped at
# the WS layer. (max_ws_message_bytes maps to the same setting since
# Ktor's WebSockets plugin only exposes per-frame caps.)
# max_ws_message_bytes = 1048576
# max_ws_frame_bytes = 1048576
[authorization]
# Allow / deny lists. Allow is a permissive ceiling; deny still
# removes specific entries inside it. Enforced by Pubkey/KindAllowDenyPolicy.
@@ -126,12 +126,13 @@ for unlimited). 500_000 is well above the floor. Pure config change in
`NegSessionRegistry.open`; expose via `[negentropy].frame_size_limit`
for operators who tune it down to fit smaller WS frame budgets.
Note: `LimitsSection.max_ws_frame_bytes`
(`geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt:148`)
applies to the WebSocket layer. After hex-encoding a 500_000-byte
negentropy payload doubles to ~1_000_000 bytes on the wire; ensure
`max_ws_frame_bytes` is at least 2 MB (or unlimited) in default
config so the response isn't truncated by the WS layer.
Note: Ktor's WebSocket layer does not impose a default frame cap, so a
~1 MB hex-encoded negentropy payload is delivered intact. The
`[limits].max_ws_frame_bytes` operator knob was removed once Ktor's
unbounded default was confirmed to be sufficient — see the
`refactor(geode): drop [limits] section` commit. If a reverse proxy
is in front of the relay, the proxy's frame cap is the only thing
that can still truncate.
### D — concurrent NEG-OPEN cap, shared with REQ
@@ -71,13 +71,6 @@ class KtorRelay(
/** Pass 0 to let the OS pick a free port. Read [url] after [start] to learn it. */
val port: Int = 0,
val path: String = "/",
/**
* Per-frame size cap; mirrors `[limits].max_ws_frame_bytes` in the
* config. Frames larger than this are rejected at the WebSocket
* layer, which is the only layer that sees the raw bytes. `null`
* uses Ktor's default (~1 MiB).
*/
val maxFrameBytes: Long? = null,
/**
* Pubkeys allowed to call NIP-86 admin RPCs. Empty (the default)
* disables the admin endpoint entirely — POSTs return 403.
@@ -100,15 +93,6 @@ class KtorRelay(
* `Host` and bind their signature to any URL.
*/
val publicUrl: String? = null,
/**
* Maximum body size accepted on the NIP-86 POST endpoint, in
* bytes. Bounded *before* auth verification because we read the
* body to compute its sha256 for NIP-98's payload binding —
* unbounded reads would let an unauthenticated attacker stream
* gigabytes and OOM the relay. 1 MiB easily fits any plausible
* RPC payload.
*/
val maxAdminBodyBytes: Int = 1 shl 20,
/**
* Ktor CIO acceptor-thread count. `null` keeps Ktor's default.
* Lift on machines with many cores when targeting 10k+
@@ -135,7 +119,13 @@ class KtorRelay(
server = nip86Server,
verifier = Nip98AuthVerifier(),
allowList = adminPubkeys.mapTo(HashSet()) { it.lowercase() },
maxBodyBytes = maxAdminBodyBytes,
// 1 MiB. Bounded *before* NIP-98 auth verification — we
// have to read the body to compute its sha256 for the
// payload binding, so an unbounded read would let an
// unauthenticated attacker stream gigabytes and OOM the
// relay. NIP-86 RPC payloads are a few hundred bytes;
// 1 MiB is ~1000× any plausible request.
maxBodyBytes = 1 shl 20,
signedUrlFor = { call ->
publicUrl ?: ("http://" + (call.request.header(HttpHeaders.Host) ?: "$host:$resolvedPort") + path)
},
@@ -192,9 +182,7 @@ class KtorRelay(
rootConfig =
serverConfig {
module {
install(WebSockets) {
maxFrameBytes?.let { maxFrameSize = it }
}
install(WebSockets)
routing {
// NIP-11: GET on the relay URL with Accept:
// application/nostr+json returns the relay info doc.
@@ -49,8 +49,8 @@ import java.io.File
*
* Every section is enforced: `[info]` populates the NIP-11 doc,
* `[network]` controls the bind, `[database]` chooses the SQLite path,
* `[options]` toggles AUTH/verify/future-skew, `[limits]` plugs into
* the relay's policy stack, and `[authorization]` seeds the runtime
* `[options]` toggles AUTH/verify/future-skew, and `[authorization]`
* seeds the runtime
* [com.vitorpamplona.quartz.nip86RelayManagement.server.BanStore] on
* first boot (see [com.vitorpamplona.geode.config.RuntimeConfig]).
*
@@ -140,18 +140,12 @@ fun main(args: Array<String>) {
parallelVerify = parallelVerify,
negentropySettings = negentropySettings,
)
// Frame cap honors max_ws_frame_bytes when set; max_ws_message_bytes
// is treated as the same cap (Ktor's WebSockets plugin only exposes
// a single per-frame limit; multi-frame messages remain unbounded).
val frameLimit =
(config.limits.max_ws_frame_bytes ?: config.limits.max_ws_message_bytes)?.toLong()
val server =
KtorRelay(
relay,
host = host,
port = port,
path = path,
maxFrameBytes = frameLimit,
adminPubkeys = config.admin.pubkeys.toSet(),
publicUrl = config.admin.public_url,
connectionGroupSize = config.network.connection_group_size,
@@ -44,7 +44,6 @@ data class StaticConfig(
val network: NetworkSection = NetworkSection(),
val database: DatabaseSection = DatabaseSection(),
val options: OptionsSection = OptionsSection(),
val limits: LimitsSection = LimitsSection(),
val authorization: AuthorizationSection = AuthorizationSection(),
val admin: AdminSection = AdminSection(),
val negentropy: NegentropySection = NegentropySection(),
@@ -160,11 +159,6 @@ data class StaticConfig(
val parallel_verify: Boolean = true,
)
data class LimitsSection(
val max_ws_message_bytes: Int? = null,
val max_ws_frame_bytes: Int? = null,
)
/**
* NIP-77 negentropy tuning. Defaults track strfry
* (`hoytech/strfry`) so a Geode relay accepts the same workload
@@ -173,9 +167,10 @@ data class StaticConfig(
*
* - [frame_size_limit] mirrors strfry's hard-coded
* `Negentropy ne(storage, 500'000)` in `RelayNegentropy.cpp`.
* Hex-encoded that's ~1 MB on the wire per NEG-MSG; ensure
* `[limits].max_ws_frame_bytes` (when set) is at least double
* this or NEG-MSGs get truncated by the WS layer.
* Hex-encoded that's ~1 MB on the wire per NEG-MSG. Ktor's
* WebSocket layer does not impose a default frame cap, so this
* payload is delivered intact unless a reverse proxy is
* misconfigured.
* - [max_sync_events] mirrors strfry's
* `relay__negentropy__maxSyncEvents`. NEG-OPEN whose snapshot
* exceeds this returns
@@ -70,9 +70,6 @@ class StaticConfigTest {
require_auth = true
reject_future_seconds = 1800
[limits]
max_ws_frame_bytes = 1048576
[authorization]
pubkey_blacklist = ["aaaa", "bbbb"]
kind_blacklist = [4, 1059]
@@ -95,8 +92,6 @@ class StaticConfigTest {
assertEquals(true, c.options.require_auth)
assertEquals(1800, c.options.reject_future_seconds)
assertEquals(1_048_576, c.limits.max_ws_frame_bytes)
assertEquals(listOf("aaaa", "bbbb"), c.authorization.pubkey_blacklist)
assertEquals(listOf(4, 1059), c.authorization.kind_blacklist)
}