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 /:symbol/returns` | `application/json` | Trailing 1/3/5/10yr returns + volatility |
| `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/candles` | `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
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
```

View file

@ -35,6 +35,11 @@ const App = struct {
/// soft cutover lets the key roll out to clients before enforcement
/// is switched on. Captured once at init.
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 {
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);
}
/// 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
fn handleIndex(_: *App, _: *httpz.Request, res: *httpz.Response) !void {
@ -188,6 +234,7 @@ fn handleHelp(_: *App, _: *httpz.Request, res: *httpz.Response) !void {
\\Endpoints:
\\ GET /{SYMBOL}/returns Trailing 1/3/5/10yr returns (JSON)
\\ 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}/candles Raw SRF cache file
\\ 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
\\ (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:
\\ lastClose Last closing price
\\ 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 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();
if (q.get("watch")) |w| {
if (std.ascii.eqlIgnoreCase(w, "true")) {
appendWatchSymbol(app, symbol) catch |err| {
log.warn("failed to append watch symbol {s}: {}", .{ symbol, err });
};
if (userAgentMayWatch(req.header("user-agent"))) {
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 {
const raw_symbol = req.param("symbol") orelse {
res.status = 400;
@ -762,13 +851,24 @@ fn fmtInt(arena: std.mem.Allocator, value: ?u8) []const u8 {
return "null";
}
/// Append a watch lot for the given symbol to the portfolio SRF file,
/// unless it already exists. Best-effort errors are logged, not fatal.
/// Append a watch lot for `symbol` to the portfolio SRF file, unless it
/// 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 {
if (!isPlausibleSymbol(symbol)) return error.InvalidSymbol;
const portfolio_path = app.environ.get("ZFIN_PORTFOLIO") orelse "portfolio.srf";
const allocator = app.allocator;
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)
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);
@ -785,7 +885,7 @@ fn appendWatchSymbol(app: *App, symbol: []const u8) !void {
}
// 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);
@memcpy(new_lots[0..portfolio.lots.len], portfolio.lots);
new_lots[portfolio.lots.len] = .{
@ -796,16 +896,10 @@ fn appendWatchSymbol(app: *App, symbol: []const u8) !void {
.security_type = .watch,
};
// Serialize and write
// Serialize and write atomically.
const output = try zfin.cache.serializePortfolio(allocator, new_lots);
defer allocator.free(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();
try writeFileAtomic(io, allocator, portfolio_path, output);
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);
defer allocator.free(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();
try writeFileAtomic(io, allocator, path, output);
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
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
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/candles", handleCandles, .{});
router.get("/:symbol/candles_meta", handleCandlesMeta, .{});
@ -1348,6 +1470,28 @@ test "keyMatches" {
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" {
try std.testing.expectEqual(@as(u8, 0), refreshExit(0, 0));
try std.testing.expectEqual(@as(u8, 75), refreshExit(0, 3));