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)
This commit is contained in:
Daniel Volz
2026-01-25 19:10:41 +01:00
committed by GitHub
parent cab0fcbba7
commit d516bdea7d
9 changed files with 87 additions and 8 deletions
+16
View File
@@ -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
+26 -3
View File
@@ -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;
+4 -3
View File
@@ -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]
+3
View File
@@ -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();
+1
View File
@@ -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,
+1
View File
@@ -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,
+16 -2
View File
@@ -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() {
</div>
<div className="med-list">
{meds.map((med) => (
<div key={med.id} className="med-row">
<div key={med.id} className={`med-row${editingId === med.id ? " editing" : ""}`}>
<div className="med-header">
<div className="med-info">
<div className="med-name-row">
@@ -358,7 +359,20 @@ export function MedicationsPage() {
<article className="card form desktop-only">
<div className="card-head">
<h2>{editingId ? t("form.editEntry") : t("form.newEntry")}</h2>
{editingId ? (
<div className="edit-header">
<MedicationAvatar
name={meds.find((m) => m.id === editingId)?.name || ""}
imageUrl={meds.find((m) => m.id === editingId)?.imageUrl}
size="md"
/>
<h2>
{t("form.editEntry")}: {meds.find((m) => m.id === editingId)?.name}
</h2>
</div>
) : (
<h2>{t("form.newEntry")}</h2>
)}
</div>
<form className="form-grid" onSubmit={saveMedication}>
<label className={fieldErrors.name ? "has-error" : ""}>
+2
View File
@@ -82,6 +82,7 @@ export function PlannerPage() {
const rows = (await fetch("/api/medications/usage", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
})
.then((res) => res.json())
@@ -113,6 +114,7 @@ export function PlannerPage() {
const res = await fetch("/api/planner/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: settings.notificationEmail,
from: range.start,
+18
View File
@@ -387,6 +387,19 @@ body.modal-open {
color: var(--accent-light);
}
.edit-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.edit-header h2 {
margin: 0;
font-size: 1.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.schedule-full {
max-width: 100%;
}
@@ -468,6 +481,11 @@ body.modal-open {
background 200ms ease,
border-color 200ms ease;
}
.med-row.editing {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, var(--bg-tertiary));
box-shadow: 0 0 0 1px var(--accent);
}
.med-header {
display: flex;
justify-content: space-between;