From d516bdea7d90a6f6bed0052f1cc2d3dfcfcdbc9c Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sun, 25 Jan 2026 19:10:41 +0100 Subject: [PATCH] fix: add credentials to all fetch calls for auth cookie support (#72) * fix: add credentials to all fetch calls for auth cookie support - Add credentials: include to useMedications.ts fetch calls - Add credentials: include to MedicationsPage.tsx save function - Add credentials: include to useSettings.ts settings update - Add credentials: include to useShare.ts share generation - Add credentials: include to DashboardPage.tsx reminder email - Add credentials: include to PlannerPage.tsx usage calculation - Make create-release workflow skip if release already exists * fix: default to ntfy-style notifications for HTTP URLs - Change notification logic to use plain text format by default - Only use JSON format for known webhook services (Discord, Slack, Telegram, Gotify) - This fixes ntfy URLs not being recognized when hostname doesn't contain 'ntfy' * feat: highlight medication being edited - Add blue border and background to the medication row being edited - Show medication avatar and name in the edit form header - Makes it easy to identify which medication is being edited when there are many * fix: use proper URL parsing for webhook detection (CodeQL security fix) Replace vulnerable .includes() URL checks with proper URL hostname parsing to prevent bypass attacks (e.g., evil.com?hooks.slack.com). Fixes CodeQL alerts #33 and #34 (js/incomplete-url-substring-sanitization) --- .github/workflows/docker-build.yml | 16 ++++++++++++++ backend/src/routes/settings.ts | 29 +++++++++++++++++++++++--- frontend/src/hooks/useMedications.ts | 7 ++++--- frontend/src/hooks/useSettings.ts | 3 +++ frontend/src/hooks/useShare.ts | 1 + frontend/src/pages/DashboardPage.tsx | 1 + frontend/src/pages/MedicationsPage.tsx | 18 ++++++++++++++-- frontend/src/pages/PlannerPage.tsx | 2 ++ frontend/src/styles.css | 18 ++++++++++++++++ 9 files changed, 87 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index a778ab1..c82a621 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -137,13 +137,28 @@ jobs: with: fetch-depth: 0 # Fetch all history for changelog generation + - name: Check if release exists + id: check_release + run: | + CURRENT_TAG=${GITHUB_REF#refs/tags/} + if gh release view "$CURRENT_TAG" &>/dev/null; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "Release $CURRENT_TAG already exists, skipping creation" + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Get previous tag + if: steps.check_release.outputs.exists == 'false' id: prev_tag run: | PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") echo "tag=${PREV_TAG}" >> $GITHUB_OUTPUT - name: Generate changelog + if: steps.check_release.outputs.exists == 'false' id: changelog run: | CURRENT_TAG=${GITHUB_REF#refs/tags/} @@ -172,6 +187,7 @@ jobs: echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${CURRENT_TAG}" >> changelog.md - name: Create GitHub Release + if: steps.check_release.outputs.exists == 'false' uses: softprops/action-gh-release@v2 with: body_path: changelog.md diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 9216057..e76c05e 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -482,10 +482,33 @@ export async function sendShoutrrrNotification( ) .trim(); - // Determine notification type based on validation result and URL pattern - const isNtfyUrl = isNtfy || sanitizedUrl.includes("ntfy.sh") || sanitizedUrl.includes("/ntfy/"); + // Determine notification type based on URL hostname + // Use JSON format only for known webhook services that require it + // Use proper URL parsing to prevent bypass attacks (e.g., evil.com?hooks.slack.com) + let isJsonWebhook = false; + try { + const parsedUrl = new URL(sanitizedUrl); + const hostname = parsedUrl.hostname.toLowerCase(); + const pathname = parsedUrl.pathname.toLowerCase(); - if (isNtfyUrl) { + isJsonWebhook = + // Discord webhooks + ((hostname === "discord.com" || hostname === "discordapp.com") && pathname.startsWith("/api/webhooks")) || + // Slack webhooks + hostname === "hooks.slack.com" || + hostname.endsWith(".hooks.slack.com") || + // Telegram API + hostname === "api.telegram.org" || + // Gotify (can be self-hosted, so check if "gotify" is in hostname) + hostname.includes("gotify"); + } catch { + // If URL parsing fails, default to ntfy-style + isJsonWebhook = false; + } + + // Default to ntfy-style (plain text with Title header) for all other HTTP URLs + // This works for ntfy, Apprise, and most simple push services + if (!isJsonWebhook) { targetUrl = sanitizedUrl; headers = { Title: cleanTitle, Tags: "pill" }; body = message; diff --git a/frontend/src/hooks/useMedications.ts b/frontend/src/hooks/useMedications.ts index 7a84913..1c782d1 100644 --- a/frontend/src/hooks/useMedications.ts +++ b/frontend/src/hooks/useMedications.ts @@ -22,7 +22,7 @@ export function useMedications(): UseMedicationsReturn { const loadMeds = useCallback(() => { setLoading(true); - fetch("/api/medications") + fetch("/api/medications", { credentials: "include" }) .then((res) => res.json()) .then((data) => setMeds(Array.isArray(data) ? data : [])) .catch(() => setMeds([])) @@ -31,7 +31,7 @@ export function useMedications(): UseMedicationsReturn { const deleteMed = useCallback( async (id: number, editingId: number | null, resetForm: () => void) => { - await fetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null); + await fetch(`/api/medications/${id}`, { method: "DELETE", credentials: "include" }).catch(() => null); if (editingId === id) resetForm(); loadMeds(); }, @@ -48,6 +48,7 @@ export function useMedications(): UseMedicationsReturn { const res = await fetch(`/api/medications/${medId}/image`, { method: "POST", body: formData, + credentials: "include", }); if (res.ok) { loadMeds(); @@ -62,7 +63,7 @@ export function useMedications(): UseMedicationsReturn { const deleteMedImage = useCallback( async (medId: number) => { - await fetch(`/api/medications/${medId}/image`, { method: "DELETE" }).catch(() => null); + await fetch(`/api/medications/${medId}/image`, { method: "DELETE", credentials: "include" }).catch(() => null); loadMeds(); }, [loadMeds] diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts index 0e95af0..748e166 100644 --- a/frontend/src/hooks/useSettings.ts +++ b/frontend/src/hooks/useSettings.ts @@ -210,6 +210,7 @@ export function useSettings(): UseSettingsReturn { await fetch("/api/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, + credentials: "include", body: JSON.stringify(payload), }).catch(() => null); @@ -233,6 +234,7 @@ export function useSettings(): UseSettingsReturn { const res = await fetch("/api/settings/test-email", { method: "POST", headers: { "Content-Type": "application/json" }, + credentials: "include", body: JSON.stringify({ email: settings.notificationEmail }), }); const data = await res.json(); @@ -254,6 +256,7 @@ export function useSettings(): UseSettingsReturn { const res = await fetch("/api/settings/test-shoutrrr", { method: "POST", headers: { "Content-Type": "application/json" }, + credentials: "include", body: JSON.stringify({ url: settings.shoutrrrUrl }), }); const data = await res.json(); diff --git a/frontend/src/hooks/useShare.ts b/frontend/src/hooks/useShare.ts index 6be1db1..91df596 100644 --- a/frontend/src/hooks/useShare.ts +++ b/frontend/src/hooks/useShare.ts @@ -59,6 +59,7 @@ export function useShare(): UseShareReturn { const res = await fetch("/api/share", { method: "POST", headers: { "Content-Type": "application/json" }, + credentials: "include", body: JSON.stringify({ takenBy: shareSelectedPerson, scheduleDays: shareSelectedDays, diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index ceb8e30..a5afe92 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -217,6 +217,7 @@ export function DashboardPage() { const res = await fetch("/api/reminder/send-email", { method: "POST", headers: { "Content-Type": "application/json" }, + credentials: "include", body: JSON.stringify({ email: settings.notificationEmail, lowStock: coverage.low, diff --git a/frontend/src/pages/MedicationsPage.tsx b/frontend/src/pages/MedicationsPage.tsx index b664b5c..11125a5 100644 --- a/frontend/src/pages/MedicationsPage.tsx +++ b/frontend/src/pages/MedicationsPage.tsx @@ -192,6 +192,7 @@ export function MedicationsPage() { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + credentials: "include", }); if (!res.ok) { @@ -308,7 +309,7 @@ export function MedicationsPage() {
{meds.map((med) => ( -
+
@@ -358,7 +359,20 @@ export function MedicationsPage() {
-

{editingId ? t("form.editEntry") : t("form.newEntry")}

+ {editingId ? ( +
+ m.id === editingId)?.name || ""} + imageUrl={meds.find((m) => m.id === editingId)?.imageUrl} + size="md" + /> +

+ {t("form.editEntry")}: {meds.find((m) => m.id === editingId)?.name} +

+
+ ) : ( +

{t("form.newEntry")}

+ )}