From 6bc4c46f03d35a43ab958d3218de793ea7139e8d Mon Sep 17 00:00:00 2001 From: Ulas Date: Sat, 4 Apr 2026 06:25:28 +0200 Subject: [PATCH] fix(security): eliminate XSS vectors and restore zoom accessibility - Extract shared esc() utility (public/utils/html.js) replacing 8 duplicate escHtml() functions across all page modules - Apply HTML escaping to all user-controlled data in innerHTML templates: titles, names, locations, descriptions, colors, notes content, weather data, autocomplete suggestions - Remove user-scalable=no and maximum-scale=1 from viewport meta tag, restoring pinch-to-zoom for WCAG 1.4.4 compliance - Bump version to 0.7.1 --- CHANGELOG.md | 10 +++++++ package.json | 2 +- public/index.html | 2 +- public/pages/budget.js | 15 ++++------ public/pages/calendar.js | 59 ++++++++++++++++----------------------- public/pages/contacts.js | 25 +++++++---------- public/pages/dashboard.js | 39 +++++++++++++------------- public/pages/meals.js | 25 ++++++----------- public/pages/notes.js | 23 ++++++--------- public/pages/settings.js | 29 ++++++------------- public/pages/shopping.js | 27 ++++++------------ public/pages/tasks.js | 32 ++++++++------------- public/utils/html.js | 27 ++++++++++++++++++ 13 files changed, 145 insertions(+), 170 deletions(-) create mode 100644 public/utils/html.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aaa091..fcdc309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.1] - 2026-04-04 + +### Security +- Fix stored XSS across all pages - extract shared `esc()` utility (`public/utils/html.js`) and apply HTML escaping to all user-controlled data in innerHTML templates (titles, names, locations, descriptions, colors, notes content, autocomplete suggestions) +- Remove `user-scalable=no` and `maximum-scale=1` from viewport meta tag - restores pinch-to-zoom accessibility (WCAG 1.4.4) + +### Changed +- Deduplicate 8 identical `escHtml()` functions (tasks, shopping, calendar, notes, meals, contacts, budget, settings) into single shared `esc()` import from `utils/html.js` +- Shared `esc()` also escapes single quotes (`'` to `'`) for safer attribute contexts + ## [0.7.0] - 2026-04-04 ### Security diff --git a/package.json b/package.json index 954ef94..b061557 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.7.0", + "version": "0.7.1", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", diff --git a/public/index.html b/public/index.html index d2ce741..bd46b6c 100644 --- a/public/index.html +++ b/public/index.html @@ -3,7 +3,7 @@ - + diff --git a/public/pages/budget.js b/public/pages/budget.js index 9ee9a26..7766f8c 100644 --- a/public/pages/budget.js +++ b/public/pages/budget.js @@ -9,6 +9,7 @@ import { api } from '/api.js'; import { openModal as openSharedModal, closeModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t, formatDate, getLocale } from '/i18n.js'; +import { esc } from '/utils/html.js'; // -------------------------------------------------------- // Konstanten @@ -253,7 +254,7 @@ function renderCategoryBars(byCategory) { return `
-
${escHtml(c.category)}
+
${esc(c.category)}
@@ -289,8 +290,8 @@ function renderEntries() {
-
${escHtml(e.title)}
- +
${esc(e.title)}
+
${sign}${formatAmount(e.amount)}
@@ -782,7 +783,7 @@ function buildEventModalContent({ mode, event, date }) { const userOpts = [ ``, ...state.users.map((u) => - `` + `` ), ].join(''); @@ -790,7 +791,7 @@ function buildEventModalContent({ mode, event, date }) {
+ placeholder="${t('calendar.titlePlaceholder')}" value="${esc(isEdit ? event.title : '')}">
@@ -839,7 +840,7 @@ function buildEventModalContent({ mode, event, date }) {
+ placeholder="${t('calendar.locationPlaceholder')}" value="${esc(isEdit && event.location ? event.location : '')}">
@@ -860,7 +861,7 @@ function buildEventModalContent({ mode, event, date }) {
+ placeholder="${t('calendar.descriptionPlaceholder')}">${esc(isEdit && event.description ? event.description : '')}
${renderRRuleFields('event', isEdit ? event.recurrence_rule : null)} @@ -950,15 +951,3 @@ async function deleteEvent(id) { } } -// -------------------------------------------------------- -// Hilfsfunktion -// -------------------------------------------------------- - -function escHtml(str) { - if (!str) return ''; - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} diff --git a/public/pages/contacts.js b/public/pages/contacts.js index f64a326..1030b80 100644 --- a/public/pages/contacts.js +++ b/public/pages/contacts.js @@ -8,6 +8,7 @@ import { api } from '/api.js'; import { openModal as openSharedModal, closeModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t } from '/i18n.js'; +import { esc } from '/utils/html.js'; // -------------------------------------------------------- // Konstanten @@ -78,7 +79,7 @@ export async function render(container, { user }) {
${CATEGORIES.map((c) => ` - + `).join('')}
@@ -196,7 +197,7 @@ function renderList() { .sort(([a], [b]) => CATEGORIES.indexOf(a) - CATEGORIES.indexOf(b)) .map(([cat, items]) => `
-
${CATEGORY_ICONS[cat] || ''} ${CATEGORY_LABELS()[cat] || escHtml(cat)}
+
${CATEGORY_ICONS[cat] || ''} ${CATEGORY_LABELS()[cat] || esc(cat)}
${items.map((c) => renderContactItem(c)).join('')}
`).join(''); @@ -220,8 +221,8 @@ function renderList() { } function renderContactItem(c) { - const phone = c.phone ? `` : ''; - const email = c.email ? `` : ''; + const phone = c.phone ? `` : ''; + const email = c.email ? `` : ''; const maps = c.address ? `` : ''; const meta = [c.phone, c.email].filter(Boolean).join(' · '); @@ -229,12 +230,12 @@ function renderContactItem(c) {
${CATEGORY_ICONS[c.category] || '📋'}
-
${escHtml(c.name)}
- ${meta ? `
${escHtml(meta)}
` : ''} +
${esc(c.name)}
+ ${meta ? `
${esc(meta)}
` : ''}
${phone}${email}${maps} - @@ -252,11 +253,11 @@ function renderContactItem(c) { function openContactModal({ mode, contact = null }) { const isEdit = mode === 'edit'; - const v = (field) => escHtml(isEdit && contact[field] ? contact[field] : ''); + const v = (field) => esc(isEdit && contact[field] ? contact[field] : ''); const catLabels = CATEGORY_LABELS(); const catOpts = CATEGORIES.map((c) => - `` + `` ).join(''); const content = ` @@ -362,12 +363,6 @@ async function deleteContact(id) { } } -function escHtml(str) { - if (!str) return ''; - return String(str) - .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} /** * Minimaler vCard 3.0/4.0 Parser. diff --git a/public/pages/dashboard.js b/public/pages/dashboard.js index 2966e61..a13bcca 100644 --- a/public/pages/dashboard.js +++ b/public/pages/dashboard.js @@ -6,6 +6,7 @@ import { api } from '/api.js'; import { t, formatDate, formatTime, getLocale } from '/i18n.js'; +import { esc } from '/utils/html.js'; // Hält den AbortController des aktuellen FAB-Listeners - wird bei jedem render() erneuert. let _fabController = null; @@ -16,9 +17,9 @@ let _fabController = null; function greeting(displayName) { const h = new Date().getHours(); - if (h < 12) return t('dashboard.greetingMorning', { name: displayName }); - if (h < 18) return t('dashboard.greetingDay', { name: displayName }); - return t('dashboard.greetingEvening', { name: displayName }); + if (h < 12) return t('dashboard.greetingMorning', { name: esc(displayName) }); + if (h < 18) return t('dashboard.greetingDay', { name: esc(displayName) }); + return t('dashboard.greetingEvening', { name: esc(displayName) }); } function formatDateTime(isoString) { @@ -130,7 +131,7 @@ function renderGreeting(user, stats = {}) { if (todayMealTitle) statChips.push(` - ${t('dashboard.todayMealChip', { title: todayMealTitle })} + ${t('dashboard.todayMealChip', { title: esc(todayMealTitle) })} `); return ` @@ -161,12 +162,12 @@ function renderUrgentTasks(tasks) {
-
${t.title}
+
${esc(t.title)}
${due ? `
${due.text}
` : ''}
${t.assigned_color ? ` -
${initials(t.assigned_name || '')}
` : ''} +
${esc(initials(t.assigned_name || ''))}
` : ''}
`; }).join(''); @@ -196,13 +197,13 @@ function renderUpcomingEvents(events) { const timeStr = e.all_day ? t('dashboard.allDay') : `${formatTime(d)}${_suffix ? ' ' + _suffix : ''}`.trim(); return `
-
+
-
${e.title}
+
${esc(e.title)}
${isToday ? t('common.today') : formatDateTime(e.start_datetime).split(',')[0]} ${timeStr} - ${e.location ? ` · ${e.location}` : ''} + ${e.location ? ` · ${esc(e.location)}` : ''}
@@ -225,7 +226,7 @@ function renderTodayMeals(meals) {
${mealLabels[type]}
-
${meal ? meal.title : '-'}
+
${meal ? esc(meal.title) : '-'}
`; }).join(''); @@ -249,9 +250,9 @@ function renderPinnedNotes(notes) { const items = notes.map((n) => `
- ${n.title ? `
${n.title}
` : ''} -
${n.content}
+ style="--note-color:${esc(n.color)};"> + ${n.title ? `
${esc(n.title)}
` : ''} +
${esc(n.content)}
`).join(''); @@ -280,7 +281,7 @@ function renderWeatherWidget(weather) {
${label}
${d.desc} + alt="${esc(d.desc)}" width="32" height="32" loading="lazy">
${d.temp_max}° ${d.temp_min}° @@ -296,15 +297,15 @@ function renderWeatherWidget(weather) {
-
${current.temp}°C
-
${current.desc}
-
${city}
+
${esc(current.temp)}°C
+
${esc(current.desc)}
+
${esc(city)}
${t('dashboard.weatherFeelsLike', { temp: current.feels_like, humidity: current.humidity, wind: current.wind_speed })}
${current.desc} + alt="${esc(current.desc)}" width="80" height="80" loading="lazy">
${forecast.length ? `
${forecastHtml}
` : ''}
diff --git a/public/pages/meals.js b/public/pages/meals.js index 1e7e14f..2600b90 100644 --- a/public/pages/meals.js +++ b/public/pages/meals.js @@ -8,6 +8,7 @@ import { api } from '/api.js'; import { openModal as openSharedModal, closeModal as closeSharedModal } from '/components/modal.js'; import { stagger } from '/utils/ux.js'; import { t, formatDate } from '/i18n.js'; +import { esc } from '/utils/html.js'; // -------------------------------------------------------- // Konstanten @@ -203,9 +204,9 @@ function renderSlot(date, type, mealsForDay) { data-action="edit-meal" data-meal-id="${meal.id}" role="button" tabindex="0"> -
${escHtml(meal.title)}
+
${esc(meal.title)}
${ingLabel ? `
- ${ingLabel}${escHtml(ingDoneLabel)} + ${ingLabel}${esc(ingDoneLabel)}
` : ''}
${canTransfer ? `
@@ -559,7 +560,7 @@ function buildModalContent({ mode, date, mealType, meal }) {
+ placeholder="${t('meals.notesPlaceholder')}">${esc(isEdit && meal.notes ? meal.notes : '')}
@@ -592,8 +593,8 @@ function buildModalContent({ mode, date, mealType, meal }) { function ingredientRowHTML(name, qty, id) { return `
- - + + @@ -719,11 +720,3 @@ async function transferMeal(mealId) { // Hilfsfunktion // -------------------------------------------------------- -function escHtml(str) { - if (!str) return ''; - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} diff --git a/public/pages/notes.js b/public/pages/notes.js index ecaf171..f594584 100644 --- a/public/pages/notes.js +++ b/public/pages/notes.js @@ -8,6 +8,7 @@ import { api } from '/api.js'; import { openModal as openSharedModal, closeModal, btnError } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t } from '/i18n.js'; +import { esc } from '/utils/html.js'; // -------------------------------------------------------- // Konstanten @@ -31,7 +32,7 @@ let _container = null; function renderMarkdownLight(text) { if (!text) return ''; - return escHtml(text) + return esc(text) .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/\*(.+?)\*/g, '$1') .replace(/^- (.+)$/gm, '• $1') @@ -54,7 +55,7 @@ export async function render(container, { user }) { + value="${esc(state.filterQuery)}">
- ${note.title ? `
${escHtml(note.title)}
` : ''} + ${note.title ? `
${esc(note.title)}
` : ''}
${renderMarkdownLight(note.content)}
@@ -498,9 +499,3 @@ function isLightColor(hex) { return (r * 299 + g * 587 + b * 114) / 1000 > 150; } -function escHtml(str) { - if (!str) return ''; - return String(str) - .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} diff --git a/public/pages/settings.js b/public/pages/settings.js index 0bd7406..b419c95 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -6,6 +6,7 @@ import { api, auth } from '/api.js'; import { t, formatDate, formatTime } from '/i18n.js'; +import { esc } from '/utils/html.js'; import '/components/oikos-locale-picker.js'; /** @@ -89,12 +90,12 @@ export async function render(container, { user }) {
@@ -484,28 +485,16 @@ function bindDeleteButtons(container, user) { }); } -// -------------------------------------------------------- -// Helfer -// -------------------------------------------------------- - -function escHtml(str) { - if (!str) return ''; - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} function memberHtml(u) { return `
  • -
    ${initials(u.display_name)}
    +
    ${initials(u.display_name)}
    - ${escHtml(u.display_name)} - @${escHtml(u.username)} · ${u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleMember')} + ${esc(u.display_name)} + @${esc(u.username)} · ${u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleMember')}
    -
  • diff --git a/public/pages/shopping.js b/public/pages/shopping.js index 4d62f6e..2f67d59 100644 --- a/public/pages/shopping.js +++ b/public/pages/shopping.js @@ -7,6 +7,7 @@ import { api } from '/api.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t } from '/i18n.js'; +import { esc } from '/utils/html.js'; // -------------------------------------------------------- // Konstanten @@ -86,7 +87,7 @@ function renderTabs(container) { return ` `; }).join(''); @@ -124,7 +125,7 @@ function renderListContent(container) {
    - ${state.activeList.name} + ${esc(state.activeList.name)}
    @@ -216,15 +217,15 @@ function renderItem(item) { data-item-id="${item.id}">
    -
    ${escHtml(item.name)}
    - ${item.quantity ? `
    ${escHtml(item.quantity)}
    ` : ''} +
    ${esc(item.name)}
    + ${item.quantity ? `
    ${esc(item.quantity)}
    ` : ''}
    @@ -256,7 +257,7 @@ function wireAutocomplete(container) { if (!suggestions.length) { dropdown.hidden = true; return; } dropdown.innerHTML = suggestions.map((s, i) => - `
    ${s}
    ` + `
    ${esc(s)}
    ` ).join(''); dropdown.hidden = false; activeIdx = -1; @@ -746,15 +747,3 @@ export async function render(container, { user }) { }); } -// -------------------------------------------------------- -// HTML-Escaping -// -------------------------------------------------------- - -function escHtml(str) { - if (!str) return ''; - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} diff --git a/public/pages/tasks.js b/public/pages/tasks.js index 97e39cf..09c9d32 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -9,15 +9,7 @@ import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js import { openModal as openSharedModal, closeModal, wireBlurValidation, btnSuccess, btnError } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t, formatDate } from '/i18n.js'; - -function escHtml(str) { - if (!str) return ''; - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} +import { esc } from '/utils/html.js'; // -------------------------------------------------------- // Konstanten @@ -161,10 +153,10 @@ function renderTaskCard(task, opts = {}) { data-subtask-id="${s.id}"> - ${escHtml(s.title)} + ${esc(s.title)}
    `).join('') : ''; @@ -173,13 +165,13 @@ function renderTaskCard(task, opts = {}) {
    - ${escHtml(task.title)} + ${esc(task.title)}
    ${renderPriorityBadge(task.priority)} @@ -190,9 +182,9 @@ function renderTaskCard(task, opts = {}) {
    ${task.assigned_color ? ` -
    - ${initials(task.assigned_name ?? '')} +
    + ${esc(initials(task.assigned_name ?? ''))}
    ` : ''}