fix(contact): keep the contact email editable and protect it from scrapers
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m20s

An earlier change rejected obfuscated entries such as
"info [á] wabenhain.com" in Admin -> Inhalte and replaced them with the
default address. The obfuscation was intentional, so:

- The admin accepts the entry as written (plain or obfuscated with
  [at], (at), {ät}, [á], <@>, [dot], [punkt]) and stores it unchanged;
  only entries that yield no valid address are rejected.
- lib/contact-email.ts turns the entry into the real address for
  mailto links and DHL labels.
- Public pages never contain the address in clear text: footer,
  contact page and every email in legal texts (Impressum, Datenschutz,
  Widerruf) render "info [at] wabenhain.com" plus an XOR-encoded payload
  with a fresh random key; the browser builds the clickable mailto link
  after load (components/protected-email.tsx).

Verified with a production build: the raw HTML of /, /kontakt,
/impressum, /datenschutz and /widerrufsbelehrung contains no plain
address, and after load each page shows mailto:[email protected].

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-26 20:35:50 +02:00
1 parent 12675f381a
commit 2943021fe9
9 files changed
+251 -35

No files matched your search

+7 -8
View File
@@ -1,9 +1,7 @@
import { cache } from "react";
import { siteConfig } from "@/lib/site-config";
import { getContentValues } from "@/lib/content";
/** Bewusst schlicht: nur "[email protected]" ohne Leerzeichen — faengt Verschleierungen wie "info [at] x.de" ab. */
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
import { normalizeContactEmail } from "@/lib/contact-email";
const COMPANY_KEYS = [
"company.name",
@@ -49,11 +47,12 @@ export const getCompanyInfo = cache(async () => {
claimAlt: v["company.claimAlt"],
description: v["company.description"],
location: v["company.location"],
// Altbestand kann eine verschleierte Adresse enthalten ("info [at] …"),
// die das Admin inzwischen ablehnt. Im Impressum und in der
// Widerrufsbelehrung muss sie direkt nutzbar sein — dann lieber die
// konfigurierte Standardadresse als eine unbrauchbare.
email: EMAIL_PATTERN.test(v["company.email"].trim()) ? v["company.email"].trim() : siteConfig.email,
// Im Admin darf die Adresse verschleiert gepflegt werden ("info [at] …").
// Hier wird sie in die echte Adresse umgerechnet — fuer mailto-Links,
// DHL-Etiketten und die geschuetzte Anzeige (components/contact-email.tsx).
// Die Standardadresse greift nur, wenn der Eintrag gar keine Adresse
// ergibt; das Admin laesst solche Eintraege nicht mehr speichern.
email: normalizeContactEmail(v["company.email"]) ?? siteConfig.email,
phone: v["company.phone"],
address: {
...siteConfig.address,
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { decodeEmailPayload, displayEmail, encodeEmailPayload, normalizeContactEmail, protectEmail } from "./contact-email";
describe("normalizeContactEmail", () => {
it.each([
["[email protected]", "[email protected]"],
[" [email protected] ", "[email protected]"],
["info [á] wabenhain.com", "[email protected]"],
["info [at] wabenhain.com", "[email protected]"],
["info(at)wabenhain(dot)com", "[email protected]"],
["info {ät} wabenhain [punkt] com", "[email protected]"],
["info [AT] wabenhain [DOT] com", "[email protected]"],
["hallo<@>wabenhain.com", "[email protected]"],
])("turns %j into a usable address", (input, expected) => {
expect(normalizeContactEmail(input)).toBe(expected);
});
it.each(["", "hallo", "info [á] ", "info @ wabenhain", "a@[email protected]", "info [á] wabenhain.com <script>"])("rejects %j", (input) => {
expect(normalizeContactEmail(input)).toBeNull();
});
});
describe("displayEmail", () => {
it("shows a readable, not directly harvestable form", () => {
expect(displayEmail("[email protected]")).toBe("info [at] wabenhain.com");
});
});
describe("email payload", () => {
it("round-trips and never contains the address in clear text", () => {
const payload = encodeEmailPayload("[email protected]", new Uint8Array([7, 42, 199, 3]));
expect(payload).not.toContain("@");
expect(payload).not.toContain("wabenhain");
expect(payload).not.toContain("info");
expect(decodeEmailPayload(payload)).toBe("[email protected]");
});
it("supports non-ASCII addresses", () => {
const payload = encodeEmailPayload("jü[email protected]", new Uint8Array([1, 2, 3]));
expect(decodeEmailPayload(payload)).toBe("jü[email protected]");
});
it("returns null for damaged payloads", () => {
expect(decodeEmailPayload("kaputt")).toBeNull();
expect(decodeEmailPayload("0102.zz")).toBeNull();
expect(decodeEmailPayload("")).toBeNull();
});
it("uses a fresh key per call", () => {
const a = protectEmail("[email protected]");
const b = protectEmail("[email protected]");
expect(a.payload).not.toBe(b.payload);
expect(decodeEmailPayload(a.payload)).toBe("[email protected]");
expect(a.fallback).toBe("info [at] wabenhain.com");
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Kontakt-E-Mail: im Admin frei pflegbar (auch in verschleierter Form wie
* "info [at] wabenhain.com") und auf der Website vor Adress-Sammlern
* geschuetzt.
*
* Schutzprinzip: Im ausgelieferten HTML steht die Adresse nie im Klartext —
* nur die lesbare Ersatzform ("info [at] wabenhain.com") und eine
* XOR-verschluesselte Zeichenkette mit zufaelligem Schluessel je Aufruf. Erst
* der Browser setzt daraus den mailto-Link zusammen
* (components/protected-email.tsx). Einfache Crawler, die nur HTML lesen,
* finden damit keine Adresse; wer JavaScript ausfuehrt, kann sie — wie jeder
* Besucher — trotzdem lesen. Vollstaendigen Schutz gibt es fuer eine
* oeffentlich angezeigte Adresse nicht.
*
* Bewusst ohne node:crypto — das Modul wird auch im Browser (Entschluesseln)
* geladen.
*/
const AT_TOKEN = /\s*[[({<]\s*(?:at|á|ä|ät|@)\s*[\])}>]\s*/gi;
const DOT_TOKEN = /\s*[[({<]\s*(?:dot|punkt|\.)\s*[\])}>]\s*/gi;
const EMAIL = /^[^\s@<>"'()[\]{}]+@[^\s@<>"'()[\]{}]+\.[^\s@<>"'()[\]{}]+$/;
const KEY_BYTES = 8;
/**
* Macht aus dem Admin-Eintrag eine verwendbare Adresse. Versteht die
* ueblichen Verschleierungen ([at], (at), {ät}, [á], <@>, [dot], [punkt]).
* null, wenn danach keine gueltige Adresse uebrig bleibt.
*/
export function normalizeContactEmail(raw: string): string | null {
const email = raw.trim().replace(AT_TOKEN, "@").replace(DOT_TOKEN, ".");
return EMAIL.test(email) ? email : null;
}
/** Lesbare Ersatzform fuer die Anzeige ohne JavaScript: "info [at] wabenhain.com". */
export function displayEmail(email: string): string {
return email.replace("@", " [at] ");
}
function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
function fromHex(hex: string): Uint8Array | null {
if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-f]+$/.test(hex)) return null;
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return bytes;
}
function xor(data: Uint8Array, key: Uint8Array): Uint8Array {
return data.map((byte, i) => byte ^ key[i % key.length]);
}
export function encodeEmailPayload(email: string, key: Uint8Array): string {
return `${toHex(key)}.${toHex(xor(new TextEncoder().encode(email), key))}`;
}
export function decodeEmailPayload(payload: string): string | null {
const [keyHex, dataHex] = payload.split(".");
const key = fromHex(keyHex ?? "");
const data = fromHex(dataHex ?? "");
if (!key || !data) return null;
try {
return new TextDecoder("utf-8", { fatal: true }).decode(xor(data, key));
} catch {
return null;
}
}
/** Serverseitig: Ersatzanzeige + verschluesselte Adresse fuer components/protected-email.tsx. */
export function protectEmail(email: string): { payload: string; fallback: string } {
const key = globalThis.crypto.getRandomValues(new Uint8Array(KEY_BYTES));
return { payload: encodeEmailPayload(email, key), fallback: displayEmail(email) };
}