diff --git a/public/neu/ansicht-bestellen.js b/public/neu/ansicht-bestellen.js index 8ccb345..d6edd7f 100644 --- a/public/neu/ansicht-bestellen.js +++ b/public/neu/ansicht-bestellen.js @@ -5,7 +5,7 @@ import { api } from './api.js' import { h, leeren } from './dom.js' -import { K, ladeAbo, safeUrl, meldeTitel, fmtEuro, fmtDatum, ABO_NICHT_GELADEN } from './ansicht-hilfen.js' +import { K, ladeAbo, sichereAdresse, meldeTitel, fmtEuro, fmtDatum, ABO_NICHT_GELADEN } from './ansicht-hilfen.js' const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs const BEDINGUNGEN = '/abo-bedingungen.html' @@ -101,7 +101,7 @@ function bestellung(daten, productKey, viewAs) { method: 'POST', body: { productKey, acceptTerms, startChoice, termsVersion: daten.termsVersion, offerId: offer?.id || '', lateStartAt: daten.lateStartAt || '' }, }) - const ziel = safeUrl(url) + const ziel = sichereAdresse(url) if (!ziel) throw new Error('Die Zahlungsseite konnte nicht geöffnet werden. Bitte später noch einmal versuchen.') window.location.href = ziel } catch (f) { diff --git a/public/neu/ansicht-hilfen.js b/public/neu/ansicht-hilfen.js index 1abe5d3..fa3bdf2 100644 --- a/public/neu/ansicht-hilfen.js +++ b/public/neu/ansicht-hilfen.js @@ -181,4 +181,11 @@ export function wechselFunktionAus(sub) { return { key: f.key, projectId: f.projectId } } +/** Adressen aus Daten (previewUrl, portalUrl, paymentUrl): http(s) wie safeUrl, dazu Pfade auf derselben Herkunft ("/vorschau-hinweis.html"). + * Nicht zugelassen: javascript:, data:, "//host" und "/\\host" (Browser lesen den Rückstrich wie einen Schrägstrich). Sonst ''. */ +export function sichereAdresse(url) { + const text = typeof url === 'string' ? url : '' + return safeUrl(text) || (/^\/(?![/\\])/.test(text) ? text : '') +} + export { safeUrl } diff --git a/public/neu/ansicht-website.js b/public/neu/ansicht-website.js index f424751..1911530 100644 --- a/public/neu/ansicht-website.js +++ b/public/neu/ansicht-website.js @@ -3,7 +3,7 @@ import { api } from './api.js' import { h, leeren } from './dom.js' -import { K, ersetze, zuAnsicht, meldeTitel, fmtDatumZeit, safeUrl } from './ansicht-hilfen.js' +import { K, ersetze, zuAnsicht, meldeTitel, fmtDatumZeit, sichereAdresse } from './ansicht-hilfen.js' import { baueAktivitaet, baueProjektDetails, ladeProjektDetails } from './website-details.js' import { ladeOffeneRechnungen, rechnungOffenZeile } from './rechnung-offen.js' import { starteInstallQuelle, installAngebot, baueInstallHinweis, istAusgeblendet, merkeAusgeblendet } from './install-hinweis.js' @@ -47,7 +47,7 @@ const fakt = (titel, wert) => h('div', null, h('dt', null, titel), h('dd', null, function projektZeile(projekt) { const bereit = vorschauBereit(projekt) - const url = safeUrl(projekt.previewUrl) + const url = sichereAdresse(projekt.previewUrl) const status = projektStatus(projekt) const rahmen = bereit && url ? h('div', { class: 'kn-vorschau', 'data-vorschau': true }, h('iframe', { src: url, loading: 'lazy', scrolling: 'no', tabindex: '-1', 'aria-hidden': 'true', title: 'Vorschau deiner Website' })) diff --git a/public/neu/ansicht-wechsel.js b/public/neu/ansicht-wechsel.js index 978a4b2..da2424e 100644 --- a/public/neu/ansicht-wechsel.js +++ b/public/neu/ansicht-wechsel.js @@ -5,7 +5,7 @@ import { api } from './api.js' import { h, leeren } from './dom.js' -import { K, ladeAbo, wechselFunktionAus, safeUrl, merkeHinweis, navigiere, meldeTitel, meldeZaehlerNeu, zuAnsicht, fmtEuro, fmtDatum, berlinTag, ABO_NICHT_GELADEN } from './ansicht-hilfen.js' +import { K, ladeAbo, wechselFunktionAus, sichereAdresse, merkeHinweis, navigiere, meldeTitel, meldeZaehlerNeu, zuAnsicht, fmtEuro, fmtDatum, berlinTag, ABO_NICHT_GELADEN } from './ansicht-hilfen.js' const SCHLUESSEL = 'kn-wechsel-funktion' const BEDINGUNGEN = '/abo-bedingungen.html' @@ -141,7 +141,7 @@ export async function zeigeWechsel(wurzel, { route, me, nav, daten } = {}) { const res = await api('/api/abo/switch', { method: 'POST', body }) if (gesendet) vergissFunktion() if (res.paymentUrl) { - const ziel = safeUrl(res.paymentUrl) + const ziel = sichereAdresse(res.paymentUrl) if (!ziel) { lauf.busy = false zeigeFehler('Die Zahlungsseite konnte nicht geöffnet werden. Der Wechsel wartet auf die Zahlung; Näheres steht unter Verträge.') diff --git a/public/neu/rechnung-offen.js b/public/neu/rechnung-offen.js index 188e541..5d1030a 100644 --- a/public/neu/rechnung-offen.js +++ b/public/neu/rechnung-offen.js @@ -3,7 +3,7 @@ import { api } from './api.js' import { h } from './dom.js' -import { fmtEuro, safeUrl, zuAnsicht } from './ansicht-hilfen.js' +import { fmtEuro, sichereAdresse, zuAnsicht } from './ansicht-hilfen.js' /** Rechnungen mit offenem Betrag, in der Reihenfolge der Antwort von GET /api/invoices. */ export function offeneRechnungen(antwort) { @@ -25,7 +25,7 @@ export function rechnungOffenZeile(offen) { if (!Array.isArray(offen) || !offen.length) return null const summe = Math.round(offen.reduce((s, r) => s + (Number(r.balance) || 0), 0) * 100) / 100 const einzel = offen.length === 1 ? offen[0] : null - const url = einzel ? safeUrl(einzel.portalUrl) : '' + const url = einzel ? sichereAdresse(einzel.portalUrl) : '' const link = url ? h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, 'Zahlen') : h('a', zuAnsicht('invoices'), 'Zahlen') diff --git a/public/neu/vertraege-abo.js b/public/neu/vertraege-abo.js index 52fd3e2..5b4f2cb 100644 --- a/public/neu/vertraege-abo.js +++ b/public/neu/vertraege-abo.js @@ -3,7 +3,7 @@ import { api } from './api.js' import { h, leeren } from './dom.js' -import { K, zuAnsicht, fmtEuro, fmtDatum, fmtTagMonat, safeUrl } from './ansicht-hilfen.js' +import { K, zuAnsicht, fmtEuro, fmtDatum, fmtTagMonat, sichereAdresse } from './ansicht-hilfen.js' const laeufe = new WeakMap() // Reiter-Inhalt → Nummer des jüngsten Aufrufs const TEXT_UST = 'Gemäß § 19 UStG ohne Umsatzsteuer.' @@ -61,7 +61,7 @@ function zahlungsKnopf(viewAs) { fehler.hidden = true try { const { url } = await api('/api/abo/portal', { method: 'POST', body: {} }) - const ziel = safeUrl(url) + const ziel = sichereAdresse(url) if (!ziel) throw new Error('Die Zahlungsseite konnte nicht geöffnet werden. Bitte versuche es später noch einmal.') window.location.href = ziel } catch (f) { @@ -77,7 +77,7 @@ function wechselKarte(daten, sub) { if (sub.productKey !== 'hosting') return null const wartung = produkt(daten, 'wartung') if (sub.switchPending) { - const url = safeUrl(sub.switchPending.paymentUrl) + const url = sichereAdresse(sub.switchPending.paymentUrl) return h('section', { class: 'kn-sektion kn-wechsel' }, h('h3', null, 'Dein Wechsel wartet auf die Zahlung'), h('p', { class: 'kn-hinweis' }, `Dein Wechsel zum Wartungs-Abo ist erst abgeschlossen, wenn die Zahlung bestätigt ist.${url ? '' : ' Schreib uns gern im Chat, wenn du Hilfe brauchst.'}`), diff --git a/public/neu/vertraege-rechnungen.js b/public/neu/vertraege-rechnungen.js index 58fadf2..d72c9f6 100644 --- a/public/neu/vertraege-rechnungen.js +++ b/public/neu/vertraege-rechnungen.js @@ -3,7 +3,7 @@ import { api } from './api.js' import { h, leeren } from './dom.js' -import { K, legacyRequest, safeUrl, fmtEuro, fmtDatum } from './ansicht-hilfen.js' +import { K, legacyRequest, sichereAdresse, fmtEuro, fmtDatum } from './ansicht-hilfen.js' import { initAuftraege } from '../auftrag-portal.js' const TEXT_LEER = 'Es liegen noch keine Rechnungen vor.' @@ -14,7 +14,7 @@ const pille = (r) => (r.overdue ? K.pille.rot : r.paid ? K.pille.gruen : r.cance function rechnungsZeile(r) { const offen = Number(r.balance) > 0 - const url = safeUrl(r.portalUrl) + const url = sichereAdresse(r.portalUrl) const links = [ url ? h('a', { class: 'kn-zeilenlink', href: url, target: '_blank', rel: 'noopener noreferrer' }, offen ? 'Zahlen' : 'Ansehen') : null, r.hasPdf !== false ? h('a', { class: 'kn-zeilenlink', href: `/api/invoices/${encodeURIComponent(r.id)}/pdf` }, 'PDF') : null, diff --git a/test/knAnsichtHilfen.test.js b/test/knAnsichtHilfen.test.js index 9be67e3..bf3d78d 100644 --- a/test/knAnsichtHilfen.test.js +++ b/test/knAnsichtHilfen.test.js @@ -2,7 +2,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { fmtEuro, fmtDatum, fmtDatumZeit, fmtTagMonat, fmtStd, fmtMinuten, fmtBytes, legacyRequest, meldeZaehlerNeu, merkeHinweis, nimmHinweis, - setzeChatVorgabe, nimmChatVorgabe, fmtUhrzeit, berlinTag, ersetze, wechselFunktionAus, zuAnsicht, meldeTitel, K, ABO_NICHT_GELADEN, navigiere, ladeAbo, safeUrl, + setzeChatVorgabe, nimmChatVorgabe, fmtUhrzeit, berlinTag, ersetze, sichereAdresse, wechselFunktionAus, zuAnsicht, meldeTitel, K, ABO_NICHT_GELADEN, navigiere, ladeAbo, safeUrl, } from '../public/neu/ansicht-hilfen.js' import { mit, mitDom, abo, aboAntwort, me, projekt, rechnung, nachricht, angebot, ledger, anfrageGutschrift, VORSCHAU, VORSCHAU_SPEICHER, LIMITS, neueWurzel } from './support/knDaten.js' @@ -97,3 +97,8 @@ test('mit(): unbekannte Pfade antworten 404, Funktionen liefern Status und Body, const c = await fetch('/api/z'); assert.equal(c.status, 404); assert.deepEqual(await c.json(), { error: 'unbekannt' }) const ev = new CustomEvent('x', { detail: 1 }); assert.equal(ev.detail, 1); ev.target = 'frei'; assert.equal(ev.target, 'frei') })) + +test('sichereAdresse: http(s) und Pfade auf derselben Herkunft, sonst leer', () => { + assert.equal(sichereAdresse('https://a.de/x'), 'https://a.de/x'); assert.equal(sichereAdresse('/vorschau-hinweis.html'), '/vorschau-hinweis.html') + for (const v of ['javascript:alert(1)', 'data:text/html,x', '//evil.example/x', '/\\evil.example/x', 'vorschau.html', '', null, undefined, 5]) assert.equal(sichereAdresse(v), '', String(v)) +})