optional API KEY (single key)
This commit is contained in:
parent
7a25df5dc8
commit
ae664da40d
2 changed files with 136 additions and 2 deletions
21
README.md
21
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
|
||||
|
||||
|
|
|
|||
117
src/main.zig
117
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 `/<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));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue