Files
HealthLog/src/lib/validations/nightscout.ts
T
Marc-André Bombeck fd6a80a921 fix(nightscout): assert the public-host floor before persisting the URL
Egress was already correct — the client fetches through safeFetch with
requirePublicHost pinned off the user's opt-in, manual redirects and a
timeout — but the check on the value we STORE was only a side effect of
the live probe the connect route happens to run first. That makes the
guard contingent on the probe's placement rather than on the input.

Run isPublicUrl at input time, gated on the same self-hoster opt-in, so
the URL that gets encrypted onto the row is the URL that was validated.
Cover the alt-notation classes the helper knows about; each case asserts
the probe was never reached, which is what distinguishes the input-time
refusal from the fetch-time one.
2026-07-19 11:40:56 +02:00

48 lines
2.1 KiB
TypeScript

import { z } from "zod/v4";
/**
* Per-user Nightscout connection input (v1.17.0). The self-hoster points
* HealthLog at their own Nightscout instance (Railway / Heroku / Fly / a LAN
* box) and pastes the instance's API token. Both fields are stored encrypted
* on `User` (`nightscoutUrlEncrypted` / `nightscoutTokenEncrypted`); the
* private-host opt-in maps to `nightscoutAllowPrivateHost`.
*
* The URL is validated to be a parseable http(s) origin here. The SSRF floor
* (public-host requirement, unless `allowPrivateHost` is set) is asserted
* twice on purpose: the connect route runs `isPublicUrl` before it persists
* anything, and `safeFetch` re-checks plus pins the resolved IP at connect
* time. The shape check here only rejects garbage early so the route can
* return a clear 422 instead of letting a malformed string reach either.
*
* The floor is NOT expressed as a Zod refinement because it depends on the
* sibling `allowPrivateHost` field and needs its own error message; folding
* it in here would collapse "malformed URL" and "private host, opt in to
* reach it" into one opaque 422.
*/
export const nightscoutConnectSchema = z.object({
// Trimmed: a trailing space / newline from a copy button reaches the
// instance verbatim and produces an opaque DNS failure.
url: z
.string()
.trim()
.min(1)
.max(2048)
.refine((value) => {
try {
const u = new URL(value);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
}, "Must be a valid http(s) URL"),
// Nightscout API tokens are opaque strings (a role-scoped access token or
// the raw `API_SECRET`). Optional: a fully public instance with
// `AUTH_DEFAULT_ROLES=readable` serves SGV entries without a token.
token: z.string().trim().max(512).optional().default(""),
// The explicit self-hoster opt-in to allow a private / LAN Nightscout host
// past the public-host SSRF floor. Defaults false (public instances).
allowPrivateHost: z.boolean().optional().default(false),
});
export type NightscoutConnectInput = z.infer<typeof nightscoutConnectSchema>;