mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
chore(release): v1.4.24
Security + accessibility hardening release. Closes the highest-priority findings from a focused security audit of the v1.4.23 release: - Bearer auth fail-closed when a route declares no scope (api-handler) - Public /api/send Umami proxy now rate-limits 120 req/min/IP - Proxy nonce generation switched to btoa(...) for Edge portability - Session-expiry delete race uses deleteMany + catch (no more 500s) - POST /api/import returns the standard apiError envelope on 422 - Runtime Docker image pins Prisma 7.8 + worker deps to minors - Notifications page renders a real load-error alert - Accessibility: switch aria-labels, htmlFor pairing, mobile admin Overview link, deep-link anchors on notification settings - Chart overlays: per-chart comparisonBaseline pref, BMI/bodyFat/ sleep/steps overlay keys, TrendCard layout tolerates long labels Verification: pnpm typecheck && pnpm lint && pnpm test (2244/2244) && pnpm format:check all green.
This commit is contained in:
1 parent
0dc0e1645c
commit
ca03c6099f
59 files changed
+841
-206
No files matched your search
@@ -6,6 +6,10 @@ import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function redact(text: string): string {
|
||||
return text.replace(/https?:\/\/\S+/gi, "[url]");
|
||||
}
|
||||
|
||||
function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
|
||||
if (!scriptUrl) return [];
|
||||
try {
|
||||
@@ -112,6 +116,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
break;
|
||||
}
|
||||
|
||||
getEvent()?.addWarning("Umami test event rejected: " + lastStatus);
|
||||
getEvent()?.addWarning(
|
||||
`Umami test event rejected: ${lastStatus}${lastDetails ? ` ${redact(lastDetails)}` : ""}`,
|
||||
);
|
||||
return apiError(`Umami test event rejected (HTTP ${lastStatus})`, 502);
|
||||
});
|
||||
@@ -308,10 +308,9 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
|
||||
// HealthKit pulse samples) cannot allocate an unbounded buffer. The
|
||||
// helper still returns the full filtered series the Pearson runners
|
||||
// need; we just bound the per-page Prisma round-trip.
|
||||
const [sysRows, diaRows, pulseRows, weightRows, moodRows, intakeRows] =
|
||||
const [sysRows, pulseRows, weightRows, moodRows, intakeRows] =
|
||||
await Promise.all([
|
||||
fetchMeasurementSeriesChunked(userId, "BLOOD_PRESSURE_SYS", { since }),
|
||||
fetchMeasurementSeriesChunked(userId, "BLOOD_PRESSURE_DIA", { since }),
|
||||
fetchMeasurementSeriesChunked(userId, "PULSE", { since }),
|
||||
fetchMeasurementSeriesChunked(userId, "WEIGHT", { since }),
|
||||
prisma.moodEntry.findMany({
|
||||
|
||||
@@ -22,7 +22,8 @@ import { apiSuccess } from "@/lib/api-response";
|
||||
* persisted server-side via an encrypted httpOnly cookie scoped to a
|
||||
* 15-minute lifetime — the same window Hydra gives the code.
|
||||
*/
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
void request;
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`codex-device-start:${user.id}`, 5, 60_000);
|
||||
|
||||
@@ -60,7 +60,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (appSettings?.githubIssueTokenEncrypted) {
|
||||
try {
|
||||
configuredToken = decrypt(appSettings.githubIssueTokenEncrypted);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
getEvent()?.addWarning("Failed to decrypt GitHub issue token");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ const prefsSchema = z.object({
|
||||
showTrendIndicator: z.boolean(),
|
||||
showTrendArrow: z.boolean(),
|
||||
showTargetRange: z.boolean(),
|
||||
comparisonBaseline: z
|
||||
.enum(["none", "lastMonth", "lastYear"])
|
||||
.default("none"),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -85,4 +85,25 @@ describe("POST /api/import — rate-limit guard", () => {
|
||||
|
||||
expect(response.status).toBeLessThan(400);
|
||||
});
|
||||
|
||||
it("returns the standard apiError envelope for invalid payloads", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: new Date(Date.now() + 1000),
|
||||
} as never);
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest("http://localhost/api/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ measurements: "not-an-array" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
const body = (await response.json()) as ApiErrorEnvelope;
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -70,7 +70,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = importSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiSuccess({ error: parsed.error.issues[0].message }, 422);
|
||||
return apiError(
|
||||
parsed.error.issues[0]?.message ?? "Invalid import payload",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
@@ -238,18 +238,16 @@ describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
|
||||
makeWorkingProvider();
|
||||
// Simulate 10 successful checkRateLimit responses, then one denial.
|
||||
let callCount = 0;
|
||||
vi.mocked(checkRateLimit).mockImplementation(
|
||||
async (_key, limit, _windowMs) => {
|
||||
callCount += 1;
|
||||
// The route must pass `10` as the limit when the env var is unset.
|
||||
expect(limit).toBe(10);
|
||||
return {
|
||||
allowed: callCount <= 10,
|
||||
remaining: Math.max(0, 10 - callCount),
|
||||
resetAt: Date.now() + 3600_000,
|
||||
};
|
||||
},
|
||||
);
|
||||
vi.mocked(checkRateLimit).mockImplementation(async (_key, limit) => {
|
||||
callCount += 1;
|
||||
// The route must pass `10` as the limit when the env var is unset.
|
||||
expect(limit).toBe(10);
|
||||
return {
|
||||
allowed: callCount <= 10,
|
||||
remaining: Math.max(0, 10 - callCount),
|
||||
resetAt: Date.now() + 3600_000,
|
||||
};
|
||||
});
|
||||
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const res = await POST(jsonRequest({ force: true }) as never);
|
||||
|
||||
@@ -48,7 +48,8 @@ function redact(text: string): string {
|
||||
.replace(/https?:\/\/\S+/gi, "[url]");
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
void request;
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "integrations.moodlog.test" } });
|
||||
|
||||
|
||||
@@ -65,7 +65,8 @@ function categoriseApiStatus(apiStatus: number): CategorisedError {
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
void request;
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "integrations.withings.test" } });
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export const GET = apiHandler(async () => {
|
||||
let categoryMap: Record<string, string> = {};
|
||||
try {
|
||||
categoryMap = await getMedicationCategories(medications.map((m) => m.id));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
getEvent()?.addWarning("Medication categories could not be loaded");
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
void request;
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "monitoring.glitchtip.test" } });
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ function redact(text: string): string {
|
||||
return text.replace(/https?:\/\/\S+/gi, "[url]");
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
void request;
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "monitoring.umami.test" } });
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
channels.push(created);
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
getEvent()?.addWarning("Legacy Telegram migration failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ function redactPushError(text: string): string {
|
||||
return text.replace(/https?:\/\/\S+/gi, "[endpoint]");
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (_request: NextRequest) => {
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
void request;
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "notifications.web-push.test" } });
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/monitoring-settings", () => ({
|
||||
getPublicMonitoringSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
});
|
||||
|
||||
describe("POST /api/send", () => {
|
||||
it("rate-limits the public Umami proxy by client IP", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest("http://localhost/api/send", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ type: "event" }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-forwarded-for": "203.0.113.10",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(
|
||||
"umami-proxy:203.0.113.10",
|
||||
120,
|
||||
60 * 1000,
|
||||
);
|
||||
expect(getPublicMonitoringSettings).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues to proxy when the request is within quota", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 119,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(getPublicMonitoringSettings).mockResolvedValue({
|
||||
umamiEnabled: true,
|
||||
umamiScriptUrl: "https://analytics.example.com/script.js",
|
||||
umamiWebsiteId: "site-1",
|
||||
} as never);
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 202 }));
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest("http://localhost/api/send", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ type: "event" }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-real-ip": "203.0.113.11",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"https://analytics.example.com/api/send",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -68,6 +70,10 @@ function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "umami.proxy" } });
|
||||
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
const rl = await checkRateLimit(`umami-proxy:${ip}`, 120, 60 * 1000);
|
||||
if (!rl.allowed) return apiError("Rate limit exceeded", 429);
|
||||
|
||||
const settings = await getPublicMonitoringSettings();
|
||||
if (!settings.umamiEnabled) {
|
||||
return NextResponse.json({}, { status: 204 });
|
||||
|
||||
Reference in new issue
Block a user