clamp down on casual watch requests
All checks were successful
Generic zig build / build (push) Successful in 1m37s
Generic zig build / deploy (push) Successful in 2m6s

This commit is contained in:
Emil Lerch 2026-06-29 14:42:24 -07:00
parent 3b014c2609
commit 2e233fe3b6
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 186 additions and 22 deletions

View file

@ -30,6 +30,7 @@ curl http://localhost:8080/AAPL/returns?fmt=xml
| `GET /symbols` | `application/json` | List of tracked symbols | | `GET /symbols` | `application/json` | List of tracked symbols |
| `GET /:symbol/returns` | `application/json` | Trailing 1/3/5/10yr returns + volatility | | `GET /:symbol/returns` | `application/json` | Trailing 1/3/5/10yr returns + volatility |
| `GET /:symbol/returns?fmt=xml` | `application/xml` | Same, XML for LibreCalc | | `GET /:symbol/returns?fmt=xml` | `application/xml` | Same, XML for LibreCalc |
| `GET /:symbol/watch` | `application/json` | Add the symbol to the watchlist (authenticated) |
| `GET /:symbol/quote` | `application/json` | Latest quote | | `GET /:symbol/quote` | `application/json` | Latest quote |
| `GET /:symbol/candles` | `application/x-srf` | Raw SRF cache file | | `GET /:symbol/candles` | `application/x-srf` | Raw SRF cache file |
| `GET /:symbol/dividends` | `application/x-srf` | Raw SRF cache file | | `GET /:symbol/dividends` | `application/x-srf` | Raw SRF cache file |
@ -68,6 +69,25 @@ 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 a soft cutover so the key can roll out to clients before enforcement is
switched on. switched on.
## Watchlist
Adding a symbol to the watchlist enrolls it in the `refresh` cron, so it
gets fetched on every run from then on -- the one operation that creates
recurring provider load. There are two ways in, gated to fit each
consumer:
- **`GET /:symbol/watch`** -- authenticated (API key), for your own
tooling deliberately growing the tracked set.
- **`GET /:symbol/returns?watch=true`** -- public, but the add only
happens when the request carries LibreOffice's `WEBSERVICE`
User-Agent. This lets the non-technical user add symbols from a
spreadsheet while keeping random callers from growing the cron set.
(Obscurity-grade: a User-Agent is trivially spoofable, which is
acceptable under the casual-traffic threat model.)
Adds are serialized and written atomically, and implausible symbols are
rejected, so a stray request can't corrupt or balloon `portfolio.srf`.
## LibreCalc usage ## LibreCalc usage
``` ```

View file

@ -35,6 +35,11 @@ const App = struct {
/// soft cutover lets the key roll out to clients before enforcement /// soft cutover lets the key roll out to clients before enforcement
/// is switched on. Captured once at init. /// is switched on. Captured once at init.
api_key: ?[]const u8, api_key: ?[]const u8,
/// Serializes the portfolio read-modify-write across concurrent
/// watchlist adds (httpz dispatches requests on multiple threads).
/// Without it two simultaneous adds could both read the old file and
/// the second writer would clobber the first's new symbol.
watch_mutex: std.Io.Mutex = .init,
fn init(io: std.Io, allocator: std.mem.Allocator, environ: *const std.process.Environ.Map) App { fn init(io: std.Io, allocator: std.mem.Allocator, environ: *const std.process.Environ.Map) App {
const config = zfin.Config.fromEnv(io, allocator, environ); const config = zfin.Config.fromEnv(io, allocator, environ);
@ -165,6 +170,47 @@ fn providedKeyMatches(req: *httpz.Request, expected: []const u8) bool {
return keyMatches(q.get("api_key"), expected); return keyMatches(q.get("api_key"), expected);
} }
/// Case-insensitive User-Agent substrings permitted to add to the
/// watchlist via the public `/:symbol/returns?watch=true` path. This is
/// obscurity-grade (a UA is trivially spoofable) and matches the
/// casual-traffic threat model: it keeps crawlers and stray browsers
/// from growing the tracked set - and thus the recurring cron-refresh
/// load - while letting the non-technical user's LibreOffice WEBSERVICE
/// calls through. The authenticated `/:symbol/watch` route bypasses this
/// (a valid API key is a stronger signal than any UA).
///
/// Confirmed empirically - LibreOffice's WEBSERVICE sends e.g.
/// "LibreOffice 24.2.7.2 denylistedbackend/8.5.0 OpenSSL/3.0.13"
/// (it also fires a WebDAV OPTIONS preflight that 404s harmlessly; the
/// real GET carries the same User-Agent). Matching the version-agnostic
/// "LibreOffice" token keeps this robust across releases.
const watch_user_agents = [_][]const u8{"LibreOffice"};
/// True if `ua` matches one of `watch_user_agents` (case-insensitive
/// substring). A null/absent User-Agent never matches.
fn userAgentMayWatch(ua: ?[]const u8) bool {
const agent = ua orelse return false;
for (watch_user_agents) |needle| {
if (std.ascii.indexOfIgnoreCase(agent, needle) != null) return true;
}
return false;
}
/// Sanity gate for symbols entering the tracked set (and thus recurring
/// cron load): non-empty, <=16 chars, and only the characters real
/// tickers use - uppercase letters, digits, and `.`/`-` for class
/// shares. Not a real ticker validator; just enough to keep junk like an
/// over-long or path-shaped segment out of the portfolio file. Symbols
/// reach here already upper-cased by `upperDupe`.
fn isPlausibleSymbol(sym: []const u8) bool {
if (sym.len == 0 or sym.len > 16) return false;
for (sym) |c| {
const ok = (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or c == '.' or c == '-';
if (!ok) return false;
}
return true;
}
// Route handlers // Route handlers
fn handleIndex(_: *App, _: *httpz.Request, res: *httpz.Response) !void { fn handleIndex(_: *App, _: *httpz.Request, res: *httpz.Response) !void {
@ -188,6 +234,7 @@ fn handleHelp(_: *App, _: *httpz.Request, res: *httpz.Response) !void {
\\Endpoints: \\Endpoints:
\\ GET /{SYMBOL}/returns Trailing 1/3/5/10yr returns (JSON) \\ GET /{SYMBOL}/returns Trailing 1/3/5/10yr returns (JSON)
\\ GET /{SYMBOL}/returns?fmt=xml Trailing returns (XML, for LibreCalc) \\ GET /{SYMBOL}/returns?fmt=xml Trailing returns (XML, for LibreCalc)
\\ GET /{SYMBOL}/watch Add SYMBOL to the watchlist (authenticated)
\\ GET /{SYMBOL}/quote Latest quote (JSON) \\ GET /{SYMBOL}/quote Latest quote (JSON)
\\ GET /{SYMBOL}/candles Raw SRF cache file \\ GET /{SYMBOL}/candles Raw SRF cache file
\\ GET /{SYMBOL}/candles_meta Candle freshness metadata (SRF) \\ GET /{SYMBOL}/candles_meta Candle freshness metadata (SRF)
@ -212,6 +259,11 @@ fn handleHelp(_: *App, _: *httpz.Request, res: *httpz.Response) !void {
\\ fetches once from the provider, fills the cache, then serves \\ fetches once from the provider, fills the cache, then serves
\\ (404 only if that fetch also fails). \\ (404 only if that fetch also fails).
\\ \\
\\Watchlist (add a symbol to the cron refresh set):
\\ GET /{SYMBOL}/watch authenticated; for your own tooling
\\ GET /{SYMBOL}/returns?watch=true public, but only LibreOffice's
\\ WEBSERVICE User-Agent is honored
\\
\\Returns fields: \\Returns fields:
\\ lastClose Last closing price \\ lastClose Last closing price
\\ trailing{1,3,5,10}YearReturn Total return with dividend reinvestment \\ trailing{1,3,5,10}YearReturn Total return with dividend reinvestment
@ -275,13 +327,20 @@ fn handleReturns(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
const arena = res.arena; const arena = res.arena;
const symbol = try upperDupe(arena, raw_symbol); const symbol = try upperDupe(arena, raw_symbol);
// Auto-add to watchlist if requested // Auto-add to watchlist if requested. UA-gated (obscurity) so only
// LibreOffice WEBSERVICE calls - not random browsers/crawlers - can
// grow the tracked set via this public endpoint. Best-effort: the
// returns response below is served regardless of whether the add ran.
const q = try req.query(); const q = try req.query();
if (q.get("watch")) |w| { if (q.get("watch")) |w| {
if (std.ascii.eqlIgnoreCase(w, "true")) { if (std.ascii.eqlIgnoreCase(w, "true")) {
appendWatchSymbol(app, symbol) catch |err| { if (userAgentMayWatch(req.header("user-agent"))) {
log.warn("failed to append watch symbol {s}: {}", .{ symbol, err }); appendWatchSymbol(app, symbol) catch |err| {
}; log.warn("failed to append watch symbol {s}: {t}", .{ symbol, err });
};
} else {
log.debug("watch add for {s} skipped: User-Agent not allowlisted", .{symbol});
}
} }
} }
@ -409,6 +468,36 @@ fn handleReturns(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
}); });
} }
/// Authenticated explicit watchlist add. Not on the public allowlist, so
/// `dispatch` requires the API key - unlike the UA-gated `?watch=true`
/// path, this is for the operator's own tooling deliberately growing the
/// tracked set (and accepting the recurring cron-refresh cost).
fn handleWatch(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
const raw_symbol = req.param("symbol") orelse {
res.status = 400;
res.body = "Missing symbol";
return;
};
const arena = res.arena;
const symbol = try upperDupe(arena, raw_symbol);
appendWatchSymbol(app, symbol) catch |err| switch (err) {
error.InvalidSymbol => {
res.status = 400;
res.body = "Invalid symbol";
return;
},
else => {
res.status = 500;
res.body = try std.fmt.allocPrint(arena, "Failed to add watch symbol: {t}", .{err});
return;
},
};
res.content_type = httpz.ContentType.JSON;
res.body = try std.fmt.allocPrint(arena, "{{\"symbol\":\"{s}\",\"watched\":true}}", .{symbol});
}
fn handleQuote(app: *App, req: *httpz.Request, res: *httpz.Response) !void { fn handleQuote(app: *App, req: *httpz.Request, res: *httpz.Response) !void {
const raw_symbol = req.param("symbol") orelse { const raw_symbol = req.param("symbol") orelse {
res.status = 400; res.status = 400;
@ -762,13 +851,24 @@ fn fmtInt(arena: std.mem.Allocator, value: ?u8) []const u8 {
return "null"; return "null";
} }
/// Append a watch lot for the given symbol to the portfolio SRF file, /// Append a watch lot for `symbol` to the portfolio SRF file, unless it
/// unless it already exists. Best-effort errors are logged, not fatal. /// is already tracked. Serialized across requests via `app.watch_mutex`
/// and written atomically, so a concurrent add or a mid-write crash can't
/// clobber or truncate the portfolio file. Returns `error.InvalidSymbol`
/// for implausible symbols; callers decide how loud to be.
fn appendWatchSymbol(app: *App, symbol: []const u8) !void { fn appendWatchSymbol(app: *App, symbol: []const u8) !void {
if (!isPlausibleSymbol(symbol)) return error.InvalidSymbol;
const portfolio_path = app.environ.get("ZFIN_PORTFOLIO") orelse "portfolio.srf"; const portfolio_path = app.environ.get("ZFIN_PORTFOLIO") orelse "portfolio.srf";
const allocator = app.allocator; const allocator = app.allocator;
const io = app.io; const io = app.io;
// Serialize the whole read-modify-write so concurrent adds don't lose
// updates (a last-writer-wins race would otherwise drop a symbol).
// Uncancelable so a canceled request can't abandon a half-done write.
app.watch_mutex.lockUncancelable(io);
defer app.watch_mutex.unlock(io);
// Read and deserialize existing portfolio (or start empty) // Read and deserialize existing portfolio (or start empty)
const file_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch |err| { const file_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
if (err == error.FileNotFound) return writeNewPortfolio(io, allocator, portfolio_path, symbol); if (err == error.FileNotFound) return writeNewPortfolio(io, allocator, portfolio_path, symbol);
@ -785,7 +885,7 @@ fn appendWatchSymbol(app: *App, symbol: []const u8) !void {
} }
// Build new lot list with the watch entry appended // Build new lot list with the watch entry appended
var new_lots = try allocator.alloc(zfin.Lot, portfolio.lots.len + 1); const new_lots = try allocator.alloc(zfin.Lot, portfolio.lots.len + 1);
defer allocator.free(new_lots); defer allocator.free(new_lots);
@memcpy(new_lots[0..portfolio.lots.len], portfolio.lots); @memcpy(new_lots[0..portfolio.lots.len], portfolio.lots);
new_lots[portfolio.lots.len] = .{ new_lots[portfolio.lots.len] = .{
@ -796,16 +896,10 @@ fn appendWatchSymbol(app: *App, symbol: []const u8) !void {
.security_type = .watch, .security_type = .watch,
}; };
// Serialize and write // Serialize and write atomically.
const output = try zfin.cache.serializePortfolio(allocator, new_lots); const output = try zfin.cache.serializePortfolio(allocator, new_lots);
defer allocator.free(output); defer allocator.free(output);
try writeFileAtomic(io, allocator, portfolio_path, output);
const file = try std.Io.Dir.cwd().createFile(io, portfolio_path, .{});
defer file.close(io);
var write_buf: [4096]u8 = undefined;
var fw = file.writer(io, &write_buf);
try fw.interface.writeAll(output);
try fw.interface.flush();
log.info("added watch symbol {s} to {s}", .{ symbol, portfolio_path }); log.info("added watch symbol {s} to {s}", .{ symbol, portfolio_path });
} }
@ -820,17 +914,41 @@ fn writeNewPortfolio(io: std.Io, allocator: std.mem.Allocator, path: []const u8,
}}; }};
const output = try zfin.cache.serializePortfolio(allocator, &lot); const output = try zfin.cache.serializePortfolio(allocator, &lot);
defer allocator.free(output); defer allocator.free(output);
try writeFileAtomic(io, allocator, path, output);
const file = try std.Io.Dir.cwd().createFile(io, path, .{});
defer file.close(io);
var write_buf: [4096]u8 = undefined;
var fw = file.writer(io, &write_buf);
try fw.interface.writeAll(output);
try fw.interface.flush();
log.info("created {s} with watch symbol {s}", .{ path, symbol }); log.info("created {s} with watch symbol {s}", .{ path, symbol });
} }
/// Crash-safe file write: write to `<path>.tmp`, fsync, then rename over
/// `path`. A mid-write crash leaves the prior file intact rather than a
/// truncated portfolio. (zfin's internal `atomic.writeFileAtomic` isn't
/// part of its public module, so we keep a small local copy.)
fn writeFileAtomic(io: std.Io, allocator: std.mem.Allocator, path: []const u8, bytes: []const u8) !void {
const tmp_path = try std.fmt.allocPrint(allocator, "{s}.tmp", .{path});
defer allocator.free(tmp_path);
{
var tmp_file = try std.Io.Dir.cwd().createFile(io, tmp_path, .{ .truncate = true, .exclusive = false });
errdefer {
tmp_file.close(io);
std.Io.Dir.cwd().deleteFile(io, tmp_path) catch |err| {
log.debug("atomic write cleanup deleteFile({s}): {t}", .{ tmp_path, err });
};
}
try tmp_file.writeStreamingAll(io, bytes);
// fsync so the data is durable before the rename appears.
try tmp_file.sync(io);
tmp_file.close(io);
}
std.Io.Dir.cwd().rename(tmp_path, std.Io.Dir.cwd(), path, io) catch |err| {
std.Io.Dir.cwd().deleteFile(io, tmp_path) catch |del_err| {
log.debug("atomic write cleanup deleteFile({s}): {t}", .{ tmp_path, del_err });
};
return err;
};
}
// Refresh command // Refresh command
fn refresh(io: std.Io, allocator: std.mem.Allocator, environ: *const std.process.Environ.Map) !u8 { fn refresh(io: std.Io, allocator: std.mem.Allocator, environ: *const std.process.Environ.Map) !u8 {
@ -1204,6 +1322,10 @@ pub fn main(init: std.process.Init) !u8 {
// Symbol routes // Symbol routes
router.get("/:symbol/returns", handleReturns, .{}); router.get("/:symbol/returns", handleReturns, .{});
// Authenticated explicit watchlist add (API key required - not on
// the public allowlist). Distinct from the UA-gated
// /:symbol/returns?watch=true path used by LibreOffice.
router.get("/:symbol/watch", handleWatch, .{});
router.get("/:symbol/quote", handleQuote, .{}); router.get("/:symbol/quote", handleQuote, .{});
router.get("/:symbol/candles", handleCandles, .{}); router.get("/:symbol/candles", handleCandles, .{});
router.get("/:symbol/candles_meta", handleCandlesMeta, .{}); router.get("/:symbol/candles_meta", handleCandlesMeta, .{});
@ -1348,6 +1470,28 @@ test "keyMatches" {
try std.testing.expect(!keyMatches("s3cretX", "s3cret")); try std.testing.expect(!keyMatches("s3cretX", "s3cret"));
} }
test "userAgentMayWatch" {
try std.testing.expect(userAgentMayWatch("LibreOffice 24.8"));
try std.testing.expect(userAgentMayWatch("libreoffice")); // case-insensitive
try std.testing.expect(userAgentMayWatch("Mozilla/5.0 LibreOffice/7.6"));
try std.testing.expect(!userAgentMayWatch("Mozilla/5.0 (X11; Linux x86_64)"));
try std.testing.expect(!userAgentMayWatch("curl/8.14.1"));
try std.testing.expect(!userAgentMayWatch(null));
try std.testing.expect(!userAgentMayWatch(""));
}
test "isPlausibleSymbol" {
try std.testing.expect(isPlausibleSymbol("AAPL"));
try std.testing.expect(isPlausibleSymbol("BRK.B"));
try std.testing.expect(isPlausibleSymbol("BRK-B"));
try std.testing.expect(isPlausibleSymbol("X"));
try std.testing.expect(!isPlausibleSymbol("")); // empty
try std.testing.expect(!isPlausibleSymbol("aapl")); // lowercase (upper-cased before this)
try std.testing.expect(!isPlausibleSymbol("AB CD")); // space
try std.testing.expect(!isPlausibleSymbol("../etc/passwd")); // path-shaped junk
try std.testing.expect(!isPlausibleSymbol("ABCDEFGHIJKLMNOPQ")); // 17 chars, too long
}
test "refreshExit: hard failure dominates, then lag, else clean" { 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, 0), refreshExit(0, 0));
try std.testing.expectEqual(@as(u8, 75), refreshExit(0, 3)); try std.testing.expectEqual(@as(u8, 75), refreshExit(0, 3));