Skip to content

Commit 7ed2558

Browse files
userFRMclaude
andauthored
fix(server): send index sec_type for per-contract index subscriptions (#878)
The per-contract WebSocket subscribe handler built `Contract::stock(symbol)` for every non-option subscribe, even when the client sent `sec_type=INDEX`. The FPSS contract wire encoding carries a load-bearing sec_type byte, so an INDEX subscription went out as a stock-typed contract: wrong instrument, no ticks. Branch on `sec_type` so INDEX builds `Contract::index(symbol)` and everything else defaults to stock, matching the wire default for a root with no security type. The selection is extracted into `non_option_contract` with unit coverage for both the index and stock arms. Also harden the flatfile date boundary: `validate_date` now rejects filesystem path separators, and `handle_get` / `handle_post` validate `date` before it is interpolated into the temp artefact path, so path safety no longer depends solely on a downstream format validator. Docs: correct the server README shutdown-token description (it is a 128-bit random hex token, 32 hex chars, not a UUID) and the example banner lines, fix the matching internal comment in the router, and add an Environment variables subsection documenting the three runtime env vars with the off-by-default rate-limit framing intact. Co-authored-by: preview <noreply@anthropic.com>
1 parent 9a1ab31 commit 7ed2558

5 files changed

Lines changed: 110 additions & 10 deletions

File tree

tools/server/README.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ The server starts:
4848

4949
Every request emits one `INFO` access-log line (method, URI, status, latency) by default. The startup banner prints `thetadatadx-server v<version>`.
5050

51+
### Environment variables
52+
53+
These runtime knobs are read from the environment, not from CLI flags. Per-IP rate limiting is off by default (matching the terminal it replaces); setting either rate-limit variable opts in. Full descriptions live in [`docs-site/docs/server/index.md`](../../docs-site/docs/server/index.md).
54+
55+
| Variable | Default | Description |
56+
|----------|---------|-------------|
57+
| `THETADATADX_RATE_LIMIT_PER_SECOND` | off | Opt into per-IP rate limiting at this many requests per second. Setting either rate-limit variable turns the limiter on. |
58+
| `THETADATADX_RATE_LIMIT_BURST_SIZE` | off | Burst size for the per-IP rate limiter. If only one of the two rate-limit variables is set, the other falls back to `20` req/s / `40` burst. |
59+
| `THETADATADX_WS_CLIENT_CAPACITY` | `4096` | Per-client WebSocket send-buffer capacity in events. A larger buffer trades memory for more headroom before a slow consumer drops events; invalid or zero values keep the default. |
60+
5161
## REST API
5262

5363
All registry endpoints are auto-generated into REST routes at startup from `ENDPOINTS`, alongside the hand-written system routes.
@@ -134,7 +144,7 @@ Send JSON commands to manage subscriptions:
134144

135145
## Hardening
136146

137-
- **`POST /v3/system/shutdown`** requires a random-UUID `X-Shutdown-Token` header printed once to stderr at startup. Token is compared in constant time so response latency does not leak the secret one byte at a time; no env var or CLI flag sets it externally. A route-scoped per-IP limiter caps attempts at roughly 3 per hour.
147+
- **`POST /v3/system/shutdown`** requires a 128-bit random hex `X-Shutdown-Token` header (32 hex chars) printed once to stderr at startup. Token is compared in constant time so response latency does not leak the secret one byte at a time; no env var or CLI flag sets it externally. A route-scoped per-IP limiter caps attempts at roughly 3 per hour.
138148
- **Global per-IP rate limit on non-loopback binds** via `tower_governor::GovernorLayer` keyed on `PeerIpKeyExtractor` (peer TCP socket, **not** `X-Forwarded-For`): 20 rps burst 40, rejected as `429` with the canonical error envelope and a `Retry-After` header. Loopback binds (`127.0.0.1`, `::1` — the default) disable the general limiter: every local client shares one peer-IP bucket, so parallel local workloads would throttle each other, which the legacy terminal never did. The shutdown-route limiter stays active on every bind. The server runs without a trusted reverse proxy, so forwarded-header extractors would let an attacker cycle fake IPs.
139149
- **256 concurrent in-flight requests** — requests past the cap queue on the layer's semaphore (they are not rejected), then queue again on the SDK's tier-sized request semaphore that matches the upstream concurrency cap. Bursts absorb as latency, not errors; see the Concurrency Model section in `docs-site/docs/server/http.md`. Upstream capacity rejections that survive the SDK's retry budget surface as `503` + `Retry-After`, not 500. **64 KiB body limit**, **4 KiB WebSocket `Message::Text` cap**.
140150
- **`BoundedQuery<32>` extractor** counts `&`-delimited query-string pairs BEFORE `serde_urlencoded` runs, so a `?a=1&b=2&...` flood is rejected at parse time rather than after HashMap rehashing allocates MB+.
@@ -145,9 +155,11 @@ Send JSON commands to manage subscriptions:
145155
Example — initiating a graceful shutdown from the same machine:
146156

147157
```bash
148-
# Server prints this line once at startup on stderr:
149-
# thetadatadx-server: X-Shutdown-Token=<UUID>
150-
curl -X POST -H "X-Shutdown-Token: <UUID>" http://127.0.0.1:25503/v3/system/shutdown
158+
# Server prints these lines once at startup on stderr (TOKEN is a
159+
# 128-bit random hex string, 32 hex chars):
160+
# Shutdown token: <TOKEN>
161+
# curl -X POST http://127.0.0.1:25503/v3/system/shutdown -H 'X-Shutdown-Token: <TOKEN>'
162+
curl -X POST -H "X-Shutdown-Token: <TOKEN>" http://127.0.0.1:25503/v3/system/shutdown
151163
```
152164

153165
## Architecture

tools/server/src/flatfile_routes.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,12 @@ async fn handle_get(
149149
Ok(f) => f,
150150
Err(e) => return error_response(StatusCode::BAD_REQUEST, "bad_request", &e),
151151
};
152+
// Validate `date` at the boundary, before it is interpolated into a
153+
// temp PathBuf in `flatfile_paths`. Path safety must not depend solely
154+
// on the downstream format validator rejecting non-YYYYMMDD first.
155+
if let Err(e) = crate::validation::validate_date(&params.date, "date") {
156+
return error_response(StatusCode::BAD_REQUEST, "bad_request", &e.message);
157+
}
152158
serve_flatfile(state, sec_type, req_type, &params.date, format).await
153159
}
154160

@@ -165,6 +171,12 @@ async fn handle_post(state: State<AppState>, body: axum::Json<FlatfileRequestBod
165171
Ok(f) => f,
166172
Err(e) => return error_response(StatusCode::BAD_REQUEST, "bad_request", &e),
167173
};
174+
// Validate `date` at the boundary, before it is interpolated into a
175+
// temp PathBuf in `flatfile_paths`. Path safety must not depend solely
176+
// on the downstream format validator rejecting non-YYYYMMDD first.
177+
if let Err(e) = crate::validation::validate_date(&body.date, "date") {
178+
return error_response(StatusCode::BAD_REQUEST, "bad_request", &e.message);
179+
}
168180
serve_flatfile(state, sec_type, req_type, &body.date, format).await
169181
}
170182

tools/server/src/router.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,9 @@ pub(crate) const GENERAL_BURST_SIZE: u32 = 40;
110110
/// setter instead sets the INTERVAL between token replenishments: one
111111
/// token per `SHUTDOWN_REPLENISH_PERIOD`. Combined with `burst_size(3)`,
112112
/// a single IP can issue at most three attempts before the bucket empties
113-
/// and must wait one full hour for each subsequent slot. UUID-v4 entropy
114-
/// already makes brute-force infeasible, but this pins an upper bound on
115-
/// guess rate regardless of token length.
113+
/// and must wait one full hour for each subsequent slot. The 128-bit
114+
/// random hex token already makes brute-force infeasible, but this pins
115+
/// an upper bound on guess rate regardless of token length.
116116
const SHUTDOWN_REPLENISH_PERIOD: Duration = Duration::from_secs(3600);
117117
const SHUTDOWN_BURST_SIZE: u32 = 3;
118118

tools/server/src/validation.rs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,29 @@ pub fn ensure_no_control_chars(value: &str, field: &'static str) -> Result<(), V
139139
Ok(())
140140
}
141141

142+
/// Reject any string containing a filesystem path separator or a parent
143+
/// reference.
144+
///
145+
/// Defense-in-depth for values that are later interpolated into a
146+
/// filesystem path (e.g. a flatfile artefact name keyed by `date`). A
147+
/// well-formed date or symbol never contains `/`, `\`, or `..`; rejecting
148+
/// them here means path safety does not depend solely on a downstream
149+
/// format validator running first. Runs after the control-char check, so
150+
/// null bytes are already gone by the time this sees the value.
151+
///
152+
/// # Errors
153+
/// Returns `ValidationError::invalid_content` when `value` contains `/`,
154+
/// `\`, or the `..` parent reference.
155+
pub fn ensure_no_path_separators(value: &str, field: &'static str) -> Result<(), ValidationError> {
156+
if value.contains('/') || value.contains('\\') || value.contains("..") {
157+
return Err(ValidationError::invalid_content(
158+
field,
159+
"contains a path separator",
160+
));
161+
}
162+
Ok(())
163+
}
164+
142165
// ---------------------------------------------------------------------------
143166
// Field-specific validators
144167
// ---------------------------------------------------------------------------
@@ -188,13 +211,15 @@ pub fn validate_symbols_list(value: &str, field: &'static str) -> Result<(), Val
188211
/// `thetadatadx::validate::validate_date` / `validate_expiration`.
189212
///
190213
/// # Errors
191-
/// Returns a `ValidationError` when `value` exceeds [`MAX_DATE_LEN`] or
192-
/// contains control characters.
214+
/// Returns a `ValidationError` when `value` exceeds [`MAX_DATE_LEN`],
215+
/// contains control characters, or contains a filesystem path separator
216+
/// (the date is interpolated into a flatfile artefact path downstream).
193217
pub fn validate_date(value: &str, field: &'static str) -> Result<(), ValidationError> {
194218
if value.len() > MAX_DATE_LEN {
195219
return Err(ValidationError::too_long(field, value.len(), MAX_DATE_LEN));
196220
}
197221
ensure_no_control_chars(value, field)?;
222+
ensure_no_path_separators(value, field)?;
198223
Ok(())
199224
}
200225

@@ -453,6 +478,19 @@ mod tests {
453478
validate_date("2026-04-20", "date").unwrap();
454479
}
455480

481+
/// A `date` interpolated into a flatfile artefact path must never carry
482+
/// a path separator or a parent reference, independent of any
483+
/// downstream format validator.
484+
#[test]
485+
fn date_rejects_path_separators() {
486+
for bad in ["../etc", "a/b", "a\\b", ".."] {
487+
assert!(
488+
validate_date(bad, "date").is_err(),
489+
"must reject path-traversal date `{bad}`"
490+
);
491+
}
492+
}
493+
456494
#[test]
457495
fn right_rejects_oversized() {
458496
let big = "C".repeat(MAX_RIGHT_LEN + 1);

tools/server/src/ws/subscribe.rs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ pub(super) async fn handle_client_message(state: &AppState, text: &str, socket:
345345
.map(|&is_call| Contract::option_raw(symbol, exp, is_call, strike))
346346
.collect::<Vec<_>>()
347347
} else {
348-
vec![Contract::stock(symbol)]
348+
vec![non_option_contract(&sec_type, symbol)]
349349
};
350350

351351
let subscriptions = match subscription_plan(&req_type, &sec_type, &contracts) {
@@ -541,6 +541,22 @@ fn parse_right_sides(raw: Option<&str>) -> Result<Vec<bool>, String> {
541541
})
542542
}
543543

544+
/// Build the per-contract contract for a non-option subscribe.
545+
///
546+
/// The FPSS contract wire encoding carries a load-bearing sec_type: an
547+
/// index addresses a different instrument map than a stock of the same
548+
/// symbol. Branch on the client `sec_type` so an `INDEX` per-contract
549+
/// subscribe is encoded as an index-typed contract instead of being
550+
/// silently sent as a stock (wrong instrument, no ticks). Options are
551+
/// handled upstream; any other value defaults to stock, matching the
552+
/// wire default for a root with no security type.
553+
fn non_option_contract(sec_type: &str, symbol: &str) -> Contract {
554+
match sec_type {
555+
"INDEX" => Contract::index(symbol),
556+
_ => Contract::stock(symbol),
557+
}
558+
}
559+
544560
/// Translate a validated subscribe command into the FPSS subscriptions
545561
/// to install (or remove).
546562
///
@@ -867,6 +883,28 @@ mod tests {
867883
assert!(kinds_ok, "both sides subscribe the same tick kind");
868884
}
869885

886+
/// A per-contract `sec_type=INDEX` subscribe builds an index-typed
887+
/// contract, not a stock. The FPSS contract wire encoding carries the
888+
/// sec_type, so a stock-typed contract for an index symbol addresses
889+
/// the wrong instrument and yields no ticks.
890+
#[test]
891+
fn non_option_index_builds_index_contract() {
892+
let c = non_option_contract("INDEX", "VIX");
893+
assert_eq!(c.sec_type, SecType::Index, "INDEX must map to index");
894+
assert_eq!(&*c.symbol, "VIX");
895+
}
896+
897+
/// A non-option subscribe with any other `sec_type` (or none) defaults
898+
/// to stock, matching the wire default for a root with no security type.
899+
#[test]
900+
fn non_option_defaults_to_stock_contract() {
901+
for st in ["STOCK", ""] {
902+
let c = non_option_contract(st, "AAPL");
903+
assert_eq!(c.sec_type, SecType::Stock, "{st:?} must map to stock");
904+
assert_eq!(&*c.symbol, "AAPL");
905+
}
906+
}
907+
870908
/// Unknown `req_type` values return ERROR with the accepted set —
871909
/// the silent-OK-without-subscribing behavior is gone.
872910
#[test]

0 commit comments

Comments
 (0)