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
This commit is contained in:
Ulas
2026-04-04 06:25:28 +02:00
parent 87186c03c0
commit 6bc4c46f03
13 changed files with 145 additions and 170 deletions
+10
View File
@@ -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
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<!-- Viewport: edge-to-edge, kein Auto-Zoom bei Inputs -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<!-- PWA / Theme -->
<meta name="theme-color" content="#007AFF" media="(prefers-color-scheme: light)" />
+5 -10
View File
@@ -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 `
<div class="budget-bar-row">
<div class="budget-bar-row__label" title="${escHtml(c.category)}">${escHtml(c.category)}</div>
<div class="budget-bar-row__label" title="${esc(c.category)}">${esc(c.category)}</div>
<div class="budget-bar-row__track">
<div class="budget-bar-row__fill ${cls}" style="width:${pct}%;"></div>
</div>
@@ -289,8 +290,8 @@ function renderEntries() {
<div class="budget-entry" data-id="${e.id}">
<div class="budget-entry__indicator ${indClass}"></div>
<div class="budget-entry__body">
<div class="budget-entry__title">${escHtml(e.title)}</div>
<div class="budget-entry__meta">${date} · ${escHtml(e.category)}${recurTag}</div>
<div class="budget-entry__title">${esc(e.title)}</div>
<div class="budget-entry__meta">${date} · ${esc(e.category)}${recurTag}</div>
</div>
<div class="budget-entry__amount ${amtClass}">${sign}${formatAmount(e.amount)}</div>
<button class="budget-entry__delete" data-action="delete" data-id="${e.id}" aria-label="${t('budget.deleteLabel')}">
@@ -354,7 +355,7 @@ function openBudgetModal({ mode, entry = null }) {
<div class="form-group">
<label class="form-label" for="bm-title">${t('budget.titleLabel')}</label>
<input type="text" class="form-input" id="bm-title"
placeholder="${t('budget.titlePlaceholder')}" value="${escHtml(isEdit ? entry.title : '')}">
placeholder="${t('budget.titlePlaceholder')}" value="${esc(isEdit ? entry.title : '')}">
</div>
<div class="form-group">
@@ -484,9 +485,3 @@ async function deleteEntry(id) {
// Hilfsfunktion
// --------------------------------------------------------
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
+24 -35
View File
@@ -9,6 +9,7 @@ import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
import { stagger } from '/utils/ux.js';
import { t, formatTime } from '/i18n.js';
import { esc } from '/utils/html.js';
// --------------------------------------------------------
// Konstanten
@@ -380,9 +381,9 @@ function renderMonthDay(date, inMonth) {
const evHtml = shown.map((ev) => `
<div class="month-day__event"
data-id="${ev.id}"
style="background-color:${escHtml(ev.color)};"
title="${escHtml(ev.title)}"
>${escHtml(ev.title)}</div>
style="background-color:${esc(ev.color)};"
title="${esc(ev.title)}"
>${esc(ev.title)}</div>
`).join('');
return `
@@ -428,8 +429,8 @@ function renderWeekView(container) {
${days.map((d, i) => `
<div class="allday-cell">
${alldayEvs[i].map((ev) => `
<div class="allday-event" data-id="${ev.id}" style="background-color:${escHtml(ev.color)};"
title="${escHtml(ev.title)}">${escHtml(ev.title)}</div>
<div class="allday-event" data-id="${ev.id}" style="background-color:${esc(ev.color)};"
title="${esc(ev.title)}">${esc(ev.title)}</div>
`).join('')}
</div>
`).join('')}
@@ -500,8 +501,8 @@ function renderWeekEvent(ev) {
return `
<div class="week-event" data-id="${ev.id}"
style="top:${top}px;height:${height}px;background-color:${escHtml(ev.color)};">
<div class="week-event__title">${escHtml(ev.title)}</div>
style="top:${top}px;height:${height}px;background-color:${esc(ev.color)};">
<div class="week-event__title">${esc(ev.title)}</div>
<div class="week-event__time">${formatTime(ev.start_datetime)}${ev.end_datetime ? '' + formatTime(ev.end_datetime) : ''}</div>
</div>
`;
@@ -539,8 +540,8 @@ function renderDayView(container) {
<div style="padding:2px 4px 2px 0;font-size:10px;color:var(--color-text-disabled);text-align:right;line-height:24px;">${t('calendar.allDayShort')}</div>
<div class="allday-cell">
${allday.map((ev) => `
<div class="allday-event" data-id="${ev.id}" style="background-color:${escHtml(ev.color)};">
${escHtml(ev.title)}
<div class="allday-event" data-id="${ev.id}" style="background-color:${esc(ev.color)};">
${esc(ev.title)}
</div>`).join('')}
</div>
</div>` : ''}
@@ -634,16 +635,16 @@ function renderAgendaEvent(ev) {
return `
<div class="agenda-event" data-id="${ev.id}">
<div class="agenda-event__color" style="background-color:${escHtml(ev.color)};"></div>
<div class="agenda-event__color" style="background-color:${esc(ev.color)};"></div>
<div class="agenda-event__body">
<div class="agenda-event__title">${escHtml(ev.title)}${(ev.recurrence_rule || ev.is_recurring_instance) ? ' <i data-lucide="repeat" style="width:12px;height:12px;display:inline;vertical-align:middle;opacity:0.5" aria-hidden="true"></i>' : ''}</div>
<div class="agenda-event__title">${esc(ev.title)}${(ev.recurrence_rule || ev.is_recurring_instance) ? ' <i data-lucide="repeat" style="width:12px;height:12px;display:inline;vertical-align:middle;opacity:0.5" aria-hidden="true"></i>' : ''}</div>
<div class="agenda-event__meta">
<span>${timeStr}</span>
${ev.location ? `<span>📍 ${escHtml(ev.location)}</span>` : ''}
${ev.location ? `<span>📍 ${esc(ev.location)}</span>` : ''}
${ev.assigned_name ? `
<span class="agenda-event__assigned">
<span class="agenda-event__avatar" style="background-color:${escHtml(ev.assigned_color || '#8E8E93')}">${initials}</span>
${escHtml(ev.assigned_name)}
<span class="agenda-event__avatar" style="background-color:${esc(ev.assigned_color || '#8E8E93')}">${initials}</span>
${esc(ev.assigned_name)}
</span>` : ''}
</div>
</div>
@@ -668,13 +669,13 @@ function showEventPopup(ev, anchor) {
+ (ev.end_datetime ? ` ${formatTime(ev.end_datetime)}${t('calendar.timeSuffix') ? ' ' + t('calendar.timeSuffix') : ''}`.trim() : '');
popup.innerHTML = `
<div class="event-popup__color-bar" style="background-color:${escHtml(ev.color)};"></div>
<div class="event-popup__title">${escHtml(ev.title)}</div>
<div class="event-popup__color-bar" style="background-color:${esc(ev.color)};"></div>
<div class="event-popup__title">${esc(ev.title)}</div>
<div class="event-popup__meta">
<div>${timeStr}</div>
${ev.location ? `<div>📍 ${escHtml(ev.location)}</div>` : ''}
${ev.description ? `<div>${escHtml(ev.description)}</div>` : ''}
${ev.assigned_name ? `<div>👤 ${escHtml(ev.assigned_name)}</div>` : ''}
${ev.location ? `<div>📍 ${esc(ev.location)}</div>` : ''}
${ev.description ? `<div>${esc(ev.description)}</div>` : ''}
${ev.assigned_name ? `<div>👤 ${esc(ev.assigned_name)}</div>` : ''}
</div>
<div class="event-popup__actions">
<button class="btn btn--secondary" style="flex:1;" id="popup-edit">${t('calendar.popupEdit')}</button>
@@ -782,7 +783,7 @@ function buildEventModalContent({ mode, event, date }) {
const userOpts = [
`<option value="">${t('calendar.assignedNobody')}</option>`,
...state.users.map((u) =>
`<option value="${u.id}" ${isEdit && event.assigned_to === u.id ? 'selected' : ''}>${escHtml(u.display_name)}</option>`
`<option value="${u.id}" ${isEdit && event.assigned_to === u.id ? 'selected' : ''}>${esc(u.display_name)}</option>`
),
].join('');
@@ -790,7 +791,7 @@ function buildEventModalContent({ mode, event, date }) {
<div class="form-group">
<label class="form-label" for="modal-title">${t('calendar.titleLabel')}</label>
<input type="text" class="form-input" id="modal-title"
placeholder="${t('calendar.titlePlaceholder')}" value="${escHtml(isEdit ? event.title : '')}">
placeholder="${t('calendar.titlePlaceholder')}" value="${esc(isEdit ? event.title : '')}">
</div>
<div class="form-group">
@@ -839,7 +840,7 @@ function buildEventModalContent({ mode, event, date }) {
<div class="form-group">
<label class="form-label" for="modal-location">${t('calendar.locationLabel')}</label>
<input type="text" class="form-input" id="modal-location"
placeholder="${t('calendar.locationPlaceholder')}" value="${escHtml(isEdit && event.location ? event.location : '')}">
placeholder="${t('calendar.locationPlaceholder')}" value="${esc(isEdit && event.location ? event.location : '')}">
</div>
<div class="form-group">
@@ -860,7 +861,7 @@ function buildEventModalContent({ mode, event, date }) {
<div class="form-group">
<label class="form-label" for="modal-description">${t('calendar.descriptionLabel')}</label>
<textarea class="form-input" id="modal-description" rows="2"
placeholder="${t('calendar.descriptionPlaceholder')}">${escHtml(isEdit && event.description ? event.description : '')}</textarea>
placeholder="${t('calendar.descriptionPlaceholder')}">${esc(isEdit && event.description ? event.description : '')}</textarea>
</div>
${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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
+10 -15
View File
@@ -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 }) {
<div class="contacts-filters" id="contacts-filters">
<button class="contact-filter-chip contact-filter-chip--active" data-cat="">${t('contacts.filterAll')}</button>
${CATEGORIES.map((c) => `
<button class="contact-filter-chip" data-cat="${escHtml(c)}">${CATEGORY_ICONS[c] || ''} ${CATEGORY_LABELS()[c] || escHtml(c)}</button>
<button class="contact-filter-chip" data-cat="${esc(c)}">${CATEGORY_ICONS[c] || ''} ${CATEGORY_LABELS()[c] || esc(c)}</button>
`).join('')}
</div>
<div id="contacts-list" class="contacts-list"></div>
@@ -196,7 +197,7 @@ function renderList() {
.sort(([a], [b]) => CATEGORIES.indexOf(a) - CATEGORIES.indexOf(b))
.map(([cat, items]) => `
<div class="contact-group">
<div class="contact-group__header">${CATEGORY_ICONS[cat] || ''} ${CATEGORY_LABELS()[cat] || escHtml(cat)}</div>
<div class="contact-group__header">${CATEGORY_ICONS[cat] || ''} ${CATEGORY_LABELS()[cat] || esc(cat)}</div>
${items.map((c) => renderContactItem(c)).join('')}
</div>
`).join('');
@@ -220,8 +221,8 @@ function renderList() {
}
function renderContactItem(c) {
const phone = c.phone ? `<a href="tel:${escHtml(c.phone)}" class="contact-action-btn contact-action-btn--call" aria-label="${t('contacts.callLabel')}"><i data-lucide="phone" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
const email = c.email ? `<a href="mailto:${escHtml(c.email)}" class="contact-action-btn contact-action-btn--mail" aria-label="${t('contacts.emailActionLabel')}"><i data-lucide="mail" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
const phone = c.phone ? `<a href="tel:${esc(c.phone)}" class="contact-action-btn contact-action-btn--call" aria-label="${t('contacts.callLabel')}"><i data-lucide="phone" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
const email = c.email ? `<a href="mailto:${esc(c.email)}" class="contact-action-btn contact-action-btn--mail" aria-label="${t('contacts.emailActionLabel')}"><i data-lucide="mail" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
const maps = c.address ? `<a href="https://maps.google.com/?q=${encodeURIComponent(c.address)}" target="_blank" rel="noopener" class="contact-action-btn contact-action-btn--maps" aria-label="${t('contacts.mapsLabel')}"><i data-lucide="map-pin" style="width:16px;height:16px;" aria-hidden="true"></i></a>` : '';
const meta = [c.phone, c.email].filter(Boolean).join(' · ');
@@ -229,12 +230,12 @@ function renderContactItem(c) {
<div class="contact-item" data-id="${c.id}">
<div class="contact-item__icon">${CATEGORY_ICONS[c.category] || '📋'}</div>
<div class="contact-item__body">
<div class="contact-item__name">${escHtml(c.name)}</div>
${meta ? `<div class="contact-item__meta">${escHtml(meta)}</div>` : ''}
<div class="contact-item__name">${esc(c.name)}</div>
${meta ? `<div class="contact-item__meta">${esc(meta)}</div>` : ''}
</div>
<div class="contact-item__actions">
${phone}${email}${maps}
<a href="/api/v1/contacts/${c.id}/vcard" download="${escHtml(c.name)}.vcf"
<a href="/api/v1/contacts/${c.id}/vcard" download="${esc(c.name)}.vcf"
class="contact-action-btn" aria-label="${t('contacts.exportLabel')}" title="${t('contacts.exportTooltip')}">
<i data-lucide="download" style="width:16px;height:16px;" aria-hidden="true"></i>
</a>
@@ -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) =>
`<option value="${c}" ${isEdit && contact.category === c ? 'selected' : ''}>${catLabels[c] || escHtml(c)}</option>`
`<option value="${c}" ${isEdit && contact.category === c ? 'selected' : ''}>${catLabels[c] || esc(c)}</option>`
).join('');
const content = `
@@ -362,12 +363,6 @@ async function deleteContact(id) {
}
}
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* Minimaler vCard 3.0/4.0 Parser.
+20 -19
View File
@@ -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(`<span class="greeting-chip">
<i data-lucide="utensils" style="${chipIcon}" aria-hidden="true"></i>
${t('dashboard.todayMealChip', { title: todayMealTitle })}
${t('dashboard.todayMealChip', { title: esc(todayMealTitle) })}
</span>`);
return `
@@ -161,12 +162,12 @@ function renderUrgentTasks(tasks) {
<div class="task-item" data-route="/tasks" role="button" tabindex="0">
<div class="task-item__priority task-item__priority--${t.priority}"></div>
<div class="task-item__content">
<div class="task-item__title">${t.title}</div>
<div class="task-item__title">${esc(t.title)}</div>
${due ? `<div class="task-item__meta ${due.overdue ? 'task-item__meta--overdue' : ''}">${due.text}</div>` : ''}
</div>
${t.assigned_color ? `
<div class="task-item__avatar" style="background-color:${t.assigned_color}"
title="${t.assigned_name || ''}">${initials(t.assigned_name || '')}</div>` : ''}
<div class="task-item__avatar" style="background-color:${esc(t.assigned_color)}"
title="${esc(t.assigned_name)}">${esc(initials(t.assigned_name || ''))}</div>` : ''}
</div>
`;
}).join('');
@@ -196,13 +197,13 @@ function renderUpcomingEvents(events) {
const timeStr = e.all_day ? t('dashboard.allDay') : `${formatTime(d)}${_suffix ? ' ' + _suffix : ''}`.trim();
return `
<div class="event-item" data-route="/calendar" role="button" tabindex="0">
<div class="event-item__bar" style="background-color:${e.color || 'var(--color-accent)'}"></div>
<div class="event-item__bar" style="background-color:${esc(e.color) || 'var(--color-accent)'}"></div>
<div class="event-item__content">
<div class="event-item__title">${e.title}</div>
<div class="event-item__title">${esc(e.title)}</div>
<div class="event-item__time">
<span class="event-time-badge ${isToday ? 'event-time-badge--today' : ''}">${isToday ? t('common.today') : formatDateTime(e.start_datetime).split(',')[0]}</span>
${timeStr}
${e.location ? ` · ${e.location}` : ''}
${e.location ? ` · ${esc(e.location)}` : ''}
</div>
</div>
</div>
@@ -225,7 +226,7 @@ function renderTodayMeals(meals) {
<div class="meal-slot ${meal ? 'meal-slot--filled' : ''}" data-route="/meals" role="button" tabindex="0">
<i data-lucide="${MEAL_ICONS[type]}" class="meal-slot__icon" aria-hidden="true"></i>
<div class="meal-slot__type">${mealLabels[type]}</div>
<div class="meal-slot__title">${meal ? meal.title : '-'}</div>
<div class="meal-slot__title">${meal ? esc(meal.title) : '-'}</div>
</div>
`;
}).join('');
@@ -249,9 +250,9 @@ function renderPinnedNotes(notes) {
const items = notes.map((n) => `
<div class="note-item" data-route="/notes" role="button" tabindex="0"
style="--note-color:${n.color};">
${n.title ? `<div class="note-item__title">${n.title}</div>` : ''}
<div class="note-item__content">${n.content}</div>
style="--note-color:${esc(n.color)};">
${n.title ? `<div class="note-item__title">${esc(n.title)}</div>` : ''}
<div class="note-item__content">${esc(n.content)}</div>
</div>
`).join('');
@@ -280,7 +281,7 @@ function renderWeatherWidget(weather) {
<div class="weather-forecast__day${extraCls}">
<div class="weather-forecast__label">${label}</div>
<img class="weather-forecast__icon" src="${WEATHER_ICON_BASE}${d.icon}"
alt="${d.desc}" width="32" height="32" loading="lazy">
alt="${esc(d.desc)}" width="32" height="32" loading="lazy">
<div class="weather-forecast__temps">
<span class="weather-forecast__high">${d.temp_max}°</span>
<span class="weather-forecast__low">${d.temp_min}°</span>
@@ -296,15 +297,15 @@ function renderWeatherWidget(weather) {
<div class="weather-widget__inner">
<div class="weather-widget__main">
<div class="weather-widget__left">
<div class="weather-widget__temp">${current.temp}°C</div>
<div class="weather-widget__desc">${current.desc}</div>
<div class="weather-widget__city">${city}</div>
<div class="weather-widget__temp">${esc(current.temp)}°C</div>
<div class="weather-widget__desc">${esc(current.desc)}</div>
<div class="weather-widget__city">${esc(city)}</div>
<div class="weather-widget__meta">
${t('dashboard.weatherFeelsLike', { temp: current.feels_like, humidity: current.humidity, wind: current.wind_speed })}
</div>
</div>
<img class="weather-widget__icon" src="${WEATHER_ICON_BASE}${current.icon}"
alt="${current.desc}" width="80" height="80" loading="lazy">
alt="${esc(current.desc)}" width="80" height="80" loading="lazy">
</div>
${forecast.length ? `<div class="weather-forecast">${forecastHtml}</div>` : ''}
</div>
+9 -16
View File
@@ -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">
<div class="meal-card__title">${escHtml(meal.title)}</div>
<div class="meal-card__title">${esc(meal.title)}</div>
${ingLabel ? `<div class="meal-card__meta">
<span class="meal-card__ingredients-count">${ingLabel}${escHtml(ingDoneLabel)}</span>
<span class="meal-card__ingredients-count">${ingLabel}${esc(ingDoneLabel)}</span>
</div>` : ''}
<div class="meal-card__actions">
${canTransfer ? `<button class="meal-card__action-btn meal-card__action-btn--shopping"
@@ -450,7 +451,7 @@ function openMealModal(opts) {
if (!res.data.length) { acDropdown.hidden = true; return; }
acIndex = -1;
acDropdown.innerHTML = res.data.map((s) => `
<div class="meal-modal__autocomplete-item" data-title="${escHtml(s.title)}">${escHtml(s.title)}</div>
<div class="meal-modal__autocomplete-item" data-title="${esc(s.title)}">${esc(s.title)}</div>
`).join('');
acDropdown.hidden = false;
} catch { acDropdown.hidden = true; }
@@ -526,7 +527,7 @@ function buildModalContent({ mode, date, mealType, meal }) {
).join('');
const listOpts = state.lists.length
? state.lists.map((l) => `<option value="${l.id}">${escHtml(l.name)}</option>`).join('')
? state.lists.map((l) => `<option value="${l.id}">${esc(l.name)}</option>`).join('')
: `<option value="" disabled>${t('meals.noShoppingLists')}</option>`;
const ingRows = isEdit && meal.ingredients?.length
@@ -551,7 +552,7 @@ function buildModalContent({ mode, date, mealType, meal }) {
<label class="form-label" for="modal-title">${t('meals.titleLabel')}</label>
<input type="text" class="form-input" id="modal-title"
placeholder="${t('meals.titlePlaceholder')}"
value="${escHtml(isEdit ? meal.title : '')}"
value="${esc(isEdit ? meal.title : '')}"
autocomplete="off">
<div id="modal-autocomplete" class="meal-modal__autocomplete" hidden></div>
</div>
@@ -559,7 +560,7 @@ function buildModalContent({ mode, date, mealType, meal }) {
<div class="form-group">
<label class="form-label" for="modal-notes">${t('meals.notesLabel')}</label>
<textarea class="form-input" id="modal-notes" rows="2"
placeholder="${t('meals.notesPlaceholder')}">${escHtml(isEdit && meal.notes ? meal.notes : '')}</textarea>
placeholder="${t('meals.notesPlaceholder')}">${esc(isEdit && meal.notes ? meal.notes : '')}</textarea>
</div>
<div class="form-group">
@@ -592,8 +593,8 @@ function buildModalContent({ mode, date, mealType, meal }) {
function ingredientRowHTML(name, qty, id) {
return `
<div class="ingredient-row" data-ing-id="${id ?? ''}">
<input type="text" class="form-input ingredient-row__name" placeholder="${t('meals.ingredientNamePlaceholder')}" value="${escHtml(name)}">
<input type="text" class="form-input ingredient-row__qty" placeholder="${t('meals.ingredientQtyPlaceholder')}" value="${escHtml(qty)}">
<input type="text" class="form-input ingredient-row__name" placeholder="${t('meals.ingredientNamePlaceholder')}" value="${esc(name)}">
<input type="text" class="form-input ingredient-row__qty" placeholder="${t('meals.ingredientQtyPlaceholder')}" value="${esc(qty)}">
<button class="ingredient-row__remove" data-action="remove-ingredient" type="button" aria-label="${t('meals.removeIngredient')}">
<i data-lucide="x" style="width:14px;height:14px;" aria-hidden="true"></i>
</button>
@@ -719,11 +720,3 @@ async function transferMeal(mealId) {
// Hilfsfunktion
// --------------------------------------------------------
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
+9 -14
View File
@@ -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, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^- (.+)$/gm, '• $1')
@@ -54,7 +55,7 @@ export async function render(container, { user }) {
<i data-lucide="search" class="notes-toolbar__search-icon" aria-hidden="true"></i>
<input type="search" id="notes-search" class="notes-toolbar__search-input"
placeholder="${t('notes.searchPlaceholder')}" autocomplete="off"
value="${escHtml(state.filterQuery)}">
value="${esc(state.filterQuery)}">
</div>
<button class="btn btn--primary" id="notes-add-btn">
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
@@ -155,18 +156,18 @@ function renderNoteCard(note) {
return `
<div class="note-card ${note.pinned ? 'note-card--pinned' : ''}"
data-id="${note.id}"
style="background-color:${escHtml(note.color)};color:${textColor};">
style="background-color:${esc(note.color)};color:${textColor};">
<button class="note-card__pin" data-action="pin" data-id="${note.id}"
aria-label="${note.pinned ? t('notes.unpinAction') : t('notes.pinAction')}">
<i data-lucide="${note.pinned ? 'pin-off' : 'pin'}" style="width:12px;height:12px;" aria-hidden="true"></i>
</button>
${note.title ? `<div class="note-card__title">${escHtml(note.title)}</div>` : ''}
${note.title ? `<div class="note-card__title">${esc(note.title)}</div>` : ''}
<div class="note-card__content">${renderMarkdownLight(note.content)}</div>
<div class="note-card__footer">
<div class="note-card__creator">
<span class="note-card__avatar"
style="background-color:${escHtml(note.creator_color || '#8E8E93')}">${initials}</span>
<span>${escHtml(note.creator_name || '')}</span>
style="background-color:${esc(note.creator_color || '#8E8E93')}">${initials}</span>
<span>${esc(note.creator_name || '')}</span>
</div>
<button class="note-card__delete" data-action="delete" data-id="${note.id}" aria-label="${t('notes.deleteLabel')}">
<i data-lucide="trash-2" style="width:12px;height:12px;" aria-hidden="true"></i>
@@ -318,7 +319,7 @@ function openNoteModal({ mode, note = null }) {
<div class="form-group">
<label class="form-label" for="note-title">${t('notes.titleLabel')}</label>
<input type="text" class="form-input" id="note-title"
placeholder="${t('notes.titlePlaceholder')}" value="${escHtml(isEdit && note.title ? note.title : '')}">
placeholder="${t('notes.titlePlaceholder')}" value="${esc(isEdit && note.title ? note.title : '')}">
</div>
<div class="form-group">
<label class="form-label" for="note-content">${t('notes.contentLabel')} <span style="font-weight:400;color:var(--text-tertiary);font-size:.85em;">${t('notes.contentMarkdownHint')}</span></label>
@@ -364,7 +365,7 @@ function openNoteModal({ mode, note = null }) {
</div>
<textarea class="form-input" id="note-content" rows="6"
placeholder="${t('notes.contentPlaceholder')}"
style="resize:vertical;">${escHtml(isEdit ? note.content : '')}</textarea>
style="resize:vertical;">${esc(isEdit ? note.content : '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">${t('notes.colorLabel')}</label>
@@ -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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
+9 -20
View File
@@ -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 }) {
<div class="settings-card">
<div class="settings-user-info">
<div class="settings-avatar" style="background:${user?.avatar_color ?? '#007AFF'}">
${initials(user?.display_name)}
<div class="settings-avatar" style="background:${esc(user?.avatar_color) || '#007AFF'}">
${esc(initials(user?.display_name))}
</div>
<div>
<div class="settings-user-info__name">${user?.display_name ?? ''}</div>
<div class="settings-user-info__username">@${user?.username ?? ''}</div>
<div class="settings-user-info__name">${esc(user?.display_name)}</div>
<div class="settings-user-info__username">@${esc(user?.username)}</div>
</div>
</div>
</div>
@@ -484,28 +485,16 @@ function bindDeleteButtons(container, user) {
});
}
// --------------------------------------------------------
// Helfer
// --------------------------------------------------------
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function memberHtml(u) {
return `
<li class="settings-member" data-id="${u.id}">
<div class="settings-avatar settings-avatar--sm" style="background:${escHtml(u.avatar_color)}">${initials(u.display_name)}</div>
<div class="settings-avatar settings-avatar--sm" style="background:${esc(u.avatar_color)}">${initials(u.display_name)}</div>
<div class="settings-member__info">
<span class="settings-member__name">${escHtml(u.display_name)}</span>
<span class="settings-member__meta">@${escHtml(u.username)} · ${u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleMember')}</span>
<span class="settings-member__name">${esc(u.display_name)}</span>
<span class="settings-member__meta">@${esc(u.username)} · ${u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleMember')}</span>
</div>
<button class="btn btn--icon btn--danger-outline" data-delete-user="${u.id}" data-name="${escHtml(u.display_name)}" aria-label="${escHtml(u.display_name)} ${t('settings.deleteMemberLabel')}" title="${t('settings.deleteMemberLabel')}">
<button class="btn btn--icon btn--danger-outline" data-delete-user="${u.id}" data-name="${esc(u.display_name)}" aria-label="${esc(u.display_name)} ${t('settings.deleteMemberLabel')}" title="${t('settings.deleteMemberLabel')}">
<i data-lucide="trash-2" aria-hidden="true"></i>
</button>
</li>
+8 -19
View File
@@ -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 `
<button class="list-tab ${list.id === state.activeListId ? 'list-tab--active' : ''}"
data-action="switch-list" data-id="${list.id}">
${list.name}
${esc(list.name)}
${list.item_total > 0 ? `<span class="list-tab__count">${unchecked > 0 ? unchecked : '✓'}</span>` : ''}
</button>`;
}).join('');
@@ -124,7 +125,7 @@ function renderListContent(container) {
<div class="list-header">
<span class="list-header__name" data-action="rename-list" data-id="${state.activeList.id}"
role="button" tabindex="0" aria-label="${t('shopping.renameListLabel')}">
${state.activeList.name}
${esc(state.activeList.name)}
<i data-lucide="pencil" class="list-header__edit-icon" aria-hidden="true"></i>
</span>
<div class="list-header__actions">
@@ -216,15 +217,15 @@ function renderItem(item) {
data-item-id="${item.id}">
<button class="item-check ${isDone ? 'item-check--checked' : ''}"
data-action="toggle-item" data-id="${item.id}" data-checked="${item.is_checked}"
aria-label="${isDone ? t('shopping.markUndoneLabel', { name: escHtml(item.name) }) : t('shopping.markDoneLabel', { name: escHtml(item.name) })}">
aria-label="${isDone ? t('shopping.markUndoneLabel', { name: esc(item.name) }) : t('shopping.markDoneLabel', { name: esc(item.name) })}">
<i data-lucide="check" class="item-check__icon" aria-hidden="true"></i>
</button>
<div class="item-body">
<div class="item-name">${escHtml(item.name)}</div>
${item.quantity ? `<div class="item-quantity">${escHtml(item.quantity)}</div>` : ''}
<div class="item-name">${esc(item.name)}</div>
${item.quantity ? `<div class="item-quantity">${esc(item.quantity)}</div>` : ''}
</div>
<button class="item-delete" data-action="delete-item" data-id="${item.id}"
aria-label="${t('shopping.deleteItemLabel', { name: escHtml(item.name) })}">
aria-label="${t('shopping.deleteItemLabel', { name: esc(item.name) })}">
<i data-lucide="x" style="width:16px;height:16px" aria-hidden="true"></i>
</button>
</div>
@@ -256,7 +257,7 @@ function wireAutocomplete(container) {
if (!suggestions.length) { dropdown.hidden = true; return; }
dropdown.innerHTML = suggestions.map((s, i) =>
`<div class="autocomplete-item" data-idx="${i}" data-value="${s}">${s}</div>`
`<div class="autocomplete-item" data-idx="${i}" data-value="${esc(s)}">${esc(s)}</div>`
).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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
+12 -20
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
import { esc } from '/utils/html.js';
// --------------------------------------------------------
// Konstanten
@@ -161,10 +153,10 @@ function renderTaskCard(task, opts = {}) {
data-subtask-id="${s.id}">
<button class="subtask-item__checkbox ${s.status === 'done' ? 'subtask-item__checkbox--done' : ''}"
data-action="toggle-subtask" data-id="${s.id}"
data-status="${s.status}" aria-label="${t('tasks.subtaskMarkDone', { title: s.title })}">
data-status="${s.status}" aria-label="${t('tasks.subtaskMarkDone', { title: esc(s.title) })}">
${s.status === 'done' ? '<i data-lucide="check" style="width:10px;height:10px;color:#fff" aria-hidden="true"></i>' : ''}
</button>
<span class="subtask-item__title">${escHtml(s.title)}</span>
<span class="subtask-item__title">${esc(s.title)}</span>
</div>`).join('')
: '';
@@ -173,13 +165,13 @@ function renderTaskCard(task, opts = {}) {
<div class="task-card__main">
<button class="task-status-btn task-status-btn--${task.status}"
data-action="toggle-status" data-id="${task.id}" data-status="${task.status}"
aria-label="${t('tasks.markDone', { title: task.title })}">
aria-label="${t('tasks.markDone', { title: esc(task.title) })}">
<i data-lucide="check" class="task-status-btn__check" aria-hidden="true"></i>
</button>
<div class="task-card__body">
<div class="task-card__title" data-action="open-task" data-id="${task.id}">
${escHtml(task.title)}
${esc(task.title)}
</div>
<div class="task-card__meta">
${renderPriorityBadge(task.priority)}
@@ -190,9 +182,9 @@ function renderTaskCard(task, opts = {}) {
</div>
${task.assigned_color ? `
<div class="task-avatar" style="background-color:${task.assigned_color}"
title="${task.assigned_name ?? ''}">
${initials(task.assigned_name ?? '')}
<div class="task-avatar" style="background-color:${esc(task.assigned_color)}"
title="${esc(task.assigned_name)}">
${esc(initials(task.assigned_name ?? ''))}
</div>` : ''}
<button class="btn btn--ghost btn--icon" data-action="edit-task" data-id="${task.id}"
@@ -252,7 +244,7 @@ function renderModalContent({ task = null, users = [] } = {}) {
const isEdit = !!task;
const userOptions = users.map((u) =>
`<option value="${u.id}" ${task?.assigned_to === u.id ? 'selected' : ''}>${u.display_name}</option>`
`<option value="${u.id}" ${task?.assigned_to === u.id ? 'selected' : ''}>${esc(u.display_name)}</option>`
).join('');
const catLabels = CATEGORY_LABELS();
@@ -272,7 +264,7 @@ function renderModalContent({ task = null, users = [] } = {}) {
<div class="form-field">
<label class="label" for="task-title">${t('tasks.titleLabel')}</label>
<input class="input" type="text" id="task-title" name="title"
value="${task?.title ?? ''}" placeholder="${t('tasks.titlePlaceholder')}"
value="${esc(task?.title)}" placeholder="${t('tasks.titlePlaceholder')}"
required autocomplete="off">
<div class="form-field__error">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
@@ -288,7 +280,7 @@ function renderModalContent({ task = null, users = [] } = {}) {
<label class="label" for="task-description">${t('tasks.descriptionLabel')}</label>
<textarea class="input" id="task-description" name="description"
rows="2" placeholder="${t('tasks.descriptionPlaceholder')}"
style="resize:vertical">${task?.description ?? ''}</textarea>
style="resize:vertical">${esc(task?.description)}</textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
@@ -513,7 +505,7 @@ function renderKanbanCard(task) {
return `
<div class="kanban-card ${task.status === 'done' ? 'kanban-card--done' : ''}"
data-task-id="${task.id}" draggable="true">
<div class="kanban-card__title">${escHtml(task.title)}</div>
<div class="kanban-card__title">${esc(task.title)}</div>
<div class="kanban-card__meta">
${renderPriorityBadge(task.priority)}
${due ? `<span class="due-date ${due.cls}"><i data-lucide="clock" style="width:10px;height:10px" aria-hidden="true"></i> ${due.label}</span>` : ''}
+27
View File
@@ -0,0 +1,27 @@
/**
* Modul: HTML Utilities
* Zweck: XSS-Schutz fuer innerHTML-basiertes Rendering
* Abhaengigkeiten: keine
*/
const ESCAPE_MAP = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
const ESCAPE_RE = /[&<>"']/g;
/**
* Escapet einen String fuer die sichere Einbettung in HTML.
* Gibt fuer null/undefined einen Leerstring zurueck.
*
* @param {*} str - Beliebiger Wert (wird zu String konvertiert)
* @returns {string} HTML-sicherer String
*/
export function esc(str) {
if (str == null) return '';
return String(str).replace(ESCAPE_RE, (ch) => ESCAPE_MAP[ch]);
}