mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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>).
175 lines
5.3 KiB
TypeScript
175 lines
5.3 KiB
TypeScript
/**
|
||
* GitHub publisher for feedback items.
|
||
*
|
||
* Centralises the GitHub Issues API interaction so /api/bugreport (legacy
|
||
* direct-submit) and /api/admin/feedback/[id]/github (admin-promotes-feedback)
|
||
* share one implementation. Reads the configured token + repo from AppSettings
|
||
* (encrypted) with env-var fallback.
|
||
*/
|
||
import { prisma } from "@/lib/db";
|
||
import { decrypt } from "@/lib/crypto";
|
||
import { getEvent } from "@/lib/logging/context";
|
||
|
||
export interface FeedbackInput {
|
||
category: string;
|
||
subject: string;
|
||
description: string;
|
||
username: string;
|
||
hasScreenshot: boolean;
|
||
metadata?: unknown;
|
||
}
|
||
|
||
export interface PublishResult {
|
||
issueNumber: number;
|
||
issueUrl: string;
|
||
}
|
||
|
||
export class GithubPublishError extends Error {
|
||
constructor(
|
||
message: string,
|
||
public status: number,
|
||
) {
|
||
super(message);
|
||
this.name = "GithubPublishError";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolves the configured GitHub token + repo from AppSettings (or env).
|
||
* Returns null if no configuration is present (caller should treat as 503).
|
||
*/
|
||
export async function getGithubConfig(): Promise<{
|
||
token: string;
|
||
repo: string;
|
||
} | null> {
|
||
const appSettings = await prisma.appSettings.findUnique({
|
||
where: { id: "singleton" },
|
||
select: {
|
||
githubIssueTokenEncrypted: true,
|
||
githubIssueRepo: true,
|
||
},
|
||
});
|
||
|
||
let configuredToken: string | null = null;
|
||
if (appSettings?.githubIssueTokenEncrypted) {
|
||
try {
|
||
configuredToken = decrypt(appSettings.githubIssueTokenEncrypted);
|
||
} catch {
|
||
getEvent()?.addWarning("Failed to decrypt GitHub issue token");
|
||
}
|
||
}
|
||
|
||
const token = configuredToken || process.env.GITHUB_ISSUE_TOKEN || "";
|
||
const repo = appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO || "";
|
||
|
||
if (!token || !repo) return null;
|
||
return { token, repo };
|
||
}
|
||
|
||
/**
|
||
* Sanitise a free-text field for GitHub markdown (strip control chars,
|
||
* neutralise fenced code blocks, hard-cap length).
|
||
*/
|
||
function sanitize(text: string, max = 5000): string {
|
||
return text.replace(/```/g, "\\`\\`\\`").slice(0, max);
|
||
}
|
||
|
||
/**
|
||
* Escape a string for safe inclusion in a `RegExp(..)` constructor — the
|
||
* standard "don't let an attacker turn input into regex metacharacters"
|
||
* helper. Used to redact secrets from logged error bodies.
|
||
*/
|
||
function escapeRegex(s: string): string {
|
||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
}
|
||
|
||
/**
|
||
* Publishes a feedback item as a GitHub Issue. Returns issue number + URL.
|
||
* Throws GithubPublishError on API failure.
|
||
*/
|
||
export async function publishFeedbackToGithub(
|
||
input: FeedbackInput,
|
||
): Promise<PublishResult> {
|
||
const config = await getGithubConfig();
|
||
if (!config) {
|
||
throw new GithubPublishError(
|
||
"GitHub publishing is not configured (missing token or repository)",
|
||
503,
|
||
);
|
||
}
|
||
|
||
const now = new Date();
|
||
const dateStr = now.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||
|
||
const safeUsername = input.username.replace(/[`*_~<>\[\]|]/g, "");
|
||
const safeSubject = sanitize(input.subject, 200);
|
||
const safeDescription = sanitize(input.description);
|
||
|
||
const categoryLabelMap: Record<string, string> = {
|
||
BUG: "bug",
|
||
FEATURE_REQUEST: "enhancement",
|
||
QUESTION: "question",
|
||
OTHER: "feedback",
|
||
};
|
||
const categoryLabel = categoryLabelMap[input.category] ?? "feedback";
|
||
|
||
let body = `**Reported by:** ${safeUsername}\n`;
|
||
body += `**Category:** ${input.category}\n`;
|
||
body += `**Date:** ${dateStr}\n\n`;
|
||
body += `## Description\n\n${safeDescription}\n`;
|
||
|
||
if (input.metadata && typeof input.metadata === "object") {
|
||
const meta = input.metadata as Record<string, unknown>;
|
||
const lines: string[] = [];
|
||
for (const [key, value] of Object.entries(meta)) {
|
||
if (value == null) continue;
|
||
const v = String(value).slice(0, 200);
|
||
lines.push(`- **${key}:** ${v.replace(/[`<>]/g, "")}`);
|
||
}
|
||
if (lines.length > 0) {
|
||
body += `\n## Context\n\n${lines.join("\n")}\n`;
|
||
}
|
||
}
|
||
|
||
if (input.hasScreenshot) {
|
||
body += `\n**Screenshot:** A screenshot was attached in the original report.`;
|
||
}
|
||
|
||
body += `\n\n---\n*Created via HealthLog feedback*`;
|
||
|
||
const title = `[${categoryLabel}] ${safeSubject || `Feedback – ${dateStr}`}`;
|
||
|
||
const res = await fetch(`https://api.github.com/repos/${config.repo}/issues`, {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${config.token}`,
|
||
"Content-Type": "application/json",
|
||
Accept: "application/vnd.github.v3+json",
|
||
},
|
||
body: JSON.stringify({
|
||
title,
|
||
body,
|
||
labels: [categoryLabel, "user-reported"],
|
||
}),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const errBody = await res.text();
|
||
// Strip the GitHub PAT before logging — GitHub error responses can echo
|
||
// request headers in some failure modes, and Wide Events flow to Loki
|
||
// unredacted. Keep the response shape useful for debugging without
|
||
// leaking the credential.
|
||
const sanitisedErr = errBody
|
||
.replace(new RegExp(escapeRegex(config.token), "g"), "[REDACTED]")
|
||
.slice(0, 500);
|
||
getEvent()?.addWarning("GitHub issue creation failed: " + sanitisedErr);
|
||
throw new GithubPublishError(
|
||
`Failed to create GitHub issue (HTTP ${res.status})`,
|
||
res.status === 401 || res.status === 403 ? res.status : 502,
|
||
);
|
||
}
|
||
|
||
const issue = (await res.json()) as { number: number; html_url: string };
|
||
return { issueNumber: issue.number, issueUrl: issue.html_url };
|
||
}
|