diff --git a/README.md b/README.md index da15e55..60ada17 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,26 @@ curl http://localhost:8080/AAPL/returns?fmt=xml | `GET /_edgar/tickers_funds` | `application/x-srf` | EDGAR mutual-fund ticker map (~3 MB) | | `GET /_edgar/tickers_companies` | `application/x-srf` | EDGAR company ticker map (~5 MB) | +## Authentication + +`/`, `/help`, and `/:symbol/returns` (the LibreCalc endpoint) are always +public. Every other endpoint -- the raw SRF cache files, `/quote`, +`/symbols`, and the EDGAR ticker maps -- is gated behind a shared key +when `ZFIN_SERVER_API_KEY` is set: + +```sh +export ZFIN_SERVER_API_KEY=your-secret + +# Supply the key as a header (preferred)... +curl -H "X-API-Key: your-secret" http://localhost:8080/AAPL/candles +# ...or as a query parameter (curl convenience) +curl "http://localhost:8080/AAPL/candles?api_key=your-secret" +``` + +If `ZFIN_SERVER_API_KEY` is unset or empty, the server is fully open -- +a soft cutover so the key can roll out to clients before enforcement is +switched on. + ## LibreCalc usage ``` @@ -100,6 +120,7 @@ All configuration is via environment variables: | `ZFIN_USER_EMAIL` | Yes | Contact email for SEC EDGAR User-Agent header | | `ZFIN_PORTFOLIO` | No | Path to portfolio SRF (default: `portfolio.srf`) | | `ZFIN_CACHE_DIR` | No | Cache directory (default: `~/.cache/zfin`) | +| `ZFIN_SERVER_API_KEY` | No | If set, require this key on every endpoint except `/`, `/help`, and `/:symbol/returns` | ## License diff --git a/src/main.zig b/src/main.zig index 57382dc..9981aa6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -27,6 +27,14 @@ const App = struct { /// to 500ms. Captured once at App.init so the dispatch hot path /// doesn't re-parse on every request. slow_threshold_ms: u64, + /// Optional shared API key. When set (via `ZFIN_SERVER_API_KEY`), + /// every endpoint except the public surface (`/`, `/help`, and + /// `/:symbol/returns`) requires a matching key, supplied either as + /// an `X-API-Key` header or an `api_key` query parameter. When null + /// (env var unset or empty) the server is fully open - this + /// soft cutover lets the key roll out to clients before enforcement + /// is switched on. Captured once at init. + api_key: ?[]const u8, fn init(io: std.Io, allocator: std.mem.Allocator, environ: *const std.process.Environ.Map) App { const config = zfin.Config.fromEnv(io, allocator, environ); @@ -35,6 +43,12 @@ const App = struct { std.fmt.parseInt(u64, s, 10) catch 500 else 500; + // Treat an empty value as unset so `ZFIN_SERVER_API_KEY=` can't + // accidentally enable enforcement with an empty (never-matching) key. + const api_key: ?[]const u8 = if (environ.get("ZFIN_SERVER_API_KEY")) |v| + (if (v.len == 0) null else v) + else + null; return .{ .io = io, .environ = environ, @@ -42,6 +56,7 @@ const App = struct { .config = config, .svc = svc, .slow_threshold_ms = slow_threshold_ms, + .api_key = api_key, }; } @@ -60,9 +75,10 @@ const App = struct { // logging. `.awake` (monotonic) avoids spurious negatives // on system clock skew. const start_ns = std.Io.Timestamp.now(self.io, .awake).nanoseconds; - try action(self, req, res); - // using defer here so we execute unconditionally + // Registered before the gate and the action so every exit path - + // a 401 from the gate, an error from the action, or a normal + // response - emits the slow/error log line. defer { const elapsed_ns = std.Io.Timestamp.now(self.io, .awake).nanoseconds - start_ns; const elapsed_ms: u64 = @intCast(@divTrunc(elapsed_ns, std.time.ns_per_ms)); @@ -80,6 +96,20 @@ const App = struct { }); } } + + // API-key gate. Soft cutover: only enforced when a key is + // configured. The public surface (`/`, `/help`, and the + // LibreOffice `/:symbol/returns` endpoint) is never gated. + if (self.api_key) |expected| { + if (!pathIsPublic(req.url.path) and !providedKeyMatches(req, expected)) { + res.status = 401; + res.content_type = httpz.ContentType.TEXT; + res.body = "Unauthorized: missing or invalid API key\n"; + return; + } + } + + try action(self, req, res); } }; @@ -89,6 +119,52 @@ fn shouldLogRequest(elapsed_ms: u64, status: u16, threshold_ms: u64) bool { return elapsed_ms > threshold_ms or status >= 400; } +/// Pure predicate: is this request path part of the public surface that +/// never requires an API key? Public = the landing page, the help text, +/// and the LibreOffice returns endpoint (`/:symbol/returns`, including +/// its `?fmt=xml` and `?watch=true` variants - the query string is not +/// part of the path). Everything else (raw SRF cache files, quotes, the +/// symbol list, EDGAR maps, entity facts) is gated. +fn pathIsPublic(path: []const u8) bool { + if (std.mem.eql(u8, path, "/")) return true; + if (std.mem.eql(u8, path, "/help")) return true; + return isSymbolReturnsPath(path); +} + +/// True only for paths shaped exactly like `//returns`: a single +/// non-empty symbol segment followed by the literal `returns`. Guards +/// against `/returns` (no symbol) and multi-segment look-alikes such as +/// `/a/b/returns` or `/AAPL/returns/extra`. +fn isSymbolReturnsPath(path: []const u8) bool { + const suffix = "/returns"; + if (path.len <= suffix.len) return false; + if (!std.mem.endsWith(u8, path, suffix)) return false; + if (path[0] != '/') return false; + const symbol = path[1 .. path.len - suffix.len]; + if (symbol.len == 0) return false; + if (std.mem.indexOfScalar(u8, symbol, '/') != null) return false; + return true; +} + +/// Length-checked equality of a caller-supplied key against the +/// configured one. The threat model is casual/incidental traffic, not a +/// timing-attack adversary, so plain `mem.eql` is sufficient. A null +/// (absent header/param) never matches. +fn keyMatches(provided: ?[]const u8, expected: []const u8) bool { + const p = provided orelse return false; + return std.mem.eql(u8, p, expected); +} + +/// Read the caller-supplied API key from the `X-API-Key` header +/// (preferred) or an `api_key` query parameter (curl convenience) and +/// compare against the configured key. httpz wants the header name in +/// lowercase. A malformed query string is treated as "no key". +fn providedKeyMatches(req: *httpz.Request, expected: []const u8) bool { + if (keyMatches(req.header("x-api-key"), expected)) return true; + const q = req.query() catch return false; + return keyMatches(q.get("api_key"), expected); +} + // ── Route handlers ─────────────────────────────────────────── fn handleIndex(_: *App, _: *httpz.Request, res: *httpz.Response) !void { @@ -1061,6 +1137,9 @@ fn printUsage(io: std.Io) !void { \\ \\Environment: \\ ZFIN_PORTFOLIO Path to portfolio SRF file (default: portfolio.srf) + \\ ZFIN_SERVER_API_KEY If set, require this key on every endpoint + \\ except /, /help, and /:symbol/returns. Supply it + \\ as an X-API-Key header or ?api_key= query param. \\ TWELVEDATA_API_KEY TwelveData API key \\ POLYGON_API_KEY Polygon API key \\ FINNHUB_API_KEY Finnhub API key @@ -1124,6 +1203,40 @@ test "shouldLogRequest: custom threshold respected" { try std.testing.expect(shouldLogRequest(2500, 200, 2000)); } +test "pathIsPublic: public surface (no key required)" { + try std.testing.expect(pathIsPublic("/")); + try std.testing.expect(pathIsPublic("/help")); + try std.testing.expect(pathIsPublic("/AAPL/returns")); + // Symbols with dots (class shares) still match. + try std.testing.expect(pathIsPublic("/BRK.B/returns")); +} + +test "pathIsPublic: gated surface (key required)" { + try std.testing.expect(!pathIsPublic("/AAPL/candles")); + try std.testing.expect(!pathIsPublic("/AAPL/quote")); + try std.testing.expect(!pathIsPublic("/symbols")); + try std.testing.expect(!pathIsPublic("/_edgar/tickers_funds")); + try std.testing.expect(!pathIsPublic("/0000320193/entity_facts")); +} + +test "pathIsPublic: returns look-alikes do not slip through" { + try std.testing.expect(!pathIsPublic("/returns")); // no symbol + try std.testing.expect(!pathIsPublic("/a/b/returns")); // extra segment + try std.testing.expect(!pathIsPublic("/AAPL/returns/extra")); // suffix, not exact + try std.testing.expect(!pathIsPublic("/help/secret")); // help prefix only + try std.testing.expect(!pathIsPublic("//returns")); // empty symbol +} + +test "keyMatches" { + try std.testing.expect(keyMatches("s3cret", "s3cret")); + try std.testing.expect(!keyMatches("s3cret", "other")); + try std.testing.expect(!keyMatches(null, "s3cret")); + try std.testing.expect(!keyMatches("", "s3cret")); + // Length-checked: neither a prefix nor an extension matches. + try std.testing.expect(!keyMatches("s3cre", "s3cret")); + try std.testing.expect(!keyMatches("s3cretX", "s3cret")); +} + test "refreshExit: hard failure dominates, then lag, else clean" { try std.testing.expectEqual(@as(u8, 0), refreshExit(0, 0)); try std.testing.expectEqual(@as(u8, 75), refreshExit(0, 3));