Files
HealthLog/src/lib/feedback/publish-github.ts
T
Marc-André Bombeck 908532be73 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>).
2026-04-27 00:28:28 +02:00

175 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}