mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
feat(security): SSRF strict IPv4, PAT logging redaction, threshold rate-limit, N+1 fix, pg-boss SIGTERM, blocking CI
Security hardening from 2026-04-26 audit:
- SEC-002: isPublicUrl() now uses a strict IPv4 parser that rejects
leading-zero forms like 010.0.0.1, which historically slipped past
the h.startsWith('10.') check. Also covers CGNAT (100.64/10),
IPv6 unique-local (fc00::/7), and additional metadata-service edge
cases. Documented as input-time only (DNS rebinding still possible
at fetch time).
- SEC-004: GitHub PAT is now redacted from logged error bodies in
publish-github.ts before flowing to Wide Events / Loki. Adds an
escapeRegex helper for safe RegExp construction.
- SEC-006: PUT /api/user/thresholds now goes through checkRateLimit
(30 writes / 5 min, per-user) — audit-log was already there but
unbounded. Makes audit-log enumeration unattractive.
- PERF-001: insights/comprehensive route now batches medication-intake
events into a single 'medicationId IN (...)' query and groups in
memory instead of N+1 per medication. Latency was scaling poorly
for users with many active meds.
- REL-003: pg-boss now installs SIGTERM/SIGINT handlers that call
boss.stop({ graceful: true, timeout: 30s }) so in-flight handlers
drain on docker stop / coolify redeploy instead of being killed.
- LOG-001: Replaced console.log/error in reminder-worker with a
workerLog helper. Lifecycle errors still go to stderr (only audience
outside any handler context); routine init messages are silent
because pg-boss own events + Wide Events from handlers carry the
state.
- CI: ESLint and TypeScript checks are now blocking — continue-on-error
removed. Tests already were. Required pre-existing 'any' types in
api-handler.ts to be properly justified with eslint-disable comments
(Next.js variadic handler signature; the bound type still constrains
return to Promise<Response>).
This commit is contained in:
1 parent
11ed6faedd
commit
908532be73
7 files changed
+173
-39
No files matched your search
@@ -7,31 +7,80 @@ export const notificationPreferenceSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
// Block private/internal network URLs to prevent SSRF. Exported so other
|
||||
// server-initiated egress (AI providers, webhooks) can reuse it.
|
||||
/**
|
||||
* Parse a dotted-quad IPv4 string into normalized octets.
|
||||
*
|
||||
* Returns null if the input is not exactly four octets, contains non-digit
|
||||
* characters, has any octet outside 0–255, or contains leading zeros (which
|
||||
* some `parseInt` paths historically allowed — bypassing prefix checks like
|
||||
* `h.startsWith("10.")` because "010.x.x.x" still parses to 10).
|
||||
*/
|
||||
function parseIpv4Strict(host: string): [number, number, number, number] | null {
|
||||
const parts = host.split(".");
|
||||
if (parts.length !== 4) return null;
|
||||
const octets: number[] = [];
|
||||
for (const part of parts) {
|
||||
if (!/^(0|[1-9][0-9]?|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/.test(part)) {
|
||||
return null;
|
||||
}
|
||||
octets.push(Number(part));
|
||||
}
|
||||
return [octets[0], octets[1], octets[2], octets[3]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Block private/internal network URLs to prevent SSRF. Exported so other
|
||||
* server-initiated egress (AI providers, webhooks) can reuse it.
|
||||
*
|
||||
* Defense layers:
|
||||
* 1. Protocol allowlist: only http(s).
|
||||
* 2. Hostname denylist for unicast loopback / link-local / metadata services.
|
||||
* 3. RFC1918 + CGNAT + 169.254/16 block via strict IPv4 parser
|
||||
* (rejects leading zeros so "010.0.0.1" cannot bypass the "10." check).
|
||||
* 4. IPv6 loopback / link-local block.
|
||||
*
|
||||
* NOTE: This is an *input-time* check on user-supplied URLs. It does not
|
||||
* defeat DNS rebinding (where a public hostname later resolves to a private
|
||||
* IP). For that, pin the resolved IP at fetch time or use an HTTP client
|
||||
* that blocks redirects to private ranges.
|
||||
*/
|
||||
export function isPublicUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return false;
|
||||
const h = parsed.hostname;
|
||||
if (
|
||||
h === "169.254.169.254" ||
|
||||
h === "127.0.0.1" ||
|
||||
h === "[::1]" ||
|
||||
h === "0.0.0.0" ||
|
||||
h === "localhost" ||
|
||||
h.startsWith("10.") ||
|
||||
h.startsWith("192.168.") ||
|
||||
h.endsWith(".internal") ||
|
||||
h.endsWith(".local")
|
||||
)
|
||||
return false;
|
||||
// 172.16.0.0/12
|
||||
if (h.startsWith("172.")) {
|
||||
const second = parseInt(h.split(".")[1] ?? "0", 10);
|
||||
if (second >= 16 && second <= 31) return false;
|
||||
}
|
||||
const h = parsed.hostname.toLowerCase();
|
||||
|
||||
// Hostname denylist (literal strings — no IP parsing involved).
|
||||
if (
|
||||
h === "localhost" ||
|
||||
h.endsWith(".internal") ||
|
||||
h.endsWith(".local") ||
|
||||
h.endsWith(".localhost")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv6 loopback / link-local / unique-local. URL.hostname strips brackets.
|
||||
if (h === "::1" || h === "[::1]" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Strict IPv4 parsing — rejects leading-zero forms like "010.0.0.1".
|
||||
const ip = parseIpv4Strict(h);
|
||||
if (ip) {
|
||||
const [a, b] = ip;
|
||||
if (a === 127) return false; // 127.0.0.0/8 loopback
|
||||
if (a === 0) return false; // 0.0.0.0/8 reserved
|
||||
if (a === 10) return false; // 10.0.0.0/8 RFC1918
|
||||
if (a === 169 && b === 254) return false; // 169.254.0.0/16 link-local + AWS metadata
|
||||
if (a === 172 && b >= 16 && b <= 31) return false; // 172.16.0.0/12 RFC1918
|
||||
if (a === 192 && b === 168) return false; // 192.168.0.0/16 RFC1918
|
||||
if (a === 100 && b >= 64 && b <= 127) return false; // 100.64.0.0/10 CGNAT
|
||||
// Any other valid IPv4 is treated as public.
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
Reference in new issue
Block a user