optional API KEY (single key)

This commit is contained in:
Emil Lerch 2026-06-29 13:42:17 -07:00
parent 7a25df5dc8
commit ae664da40d
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 136 additions and 2 deletions

View file

@ -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

View file

@ -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 `/<symbol>/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));