
Agentur-Website

Unternehmenswebsite, die ich für die von mir mitgegründete Webentwicklungsagentur entworfen und entwickelt habe. Präsentiert unsere digitalen Lösungen und Dienstleistungen.
Die Website unserer eigenen Agentur musste der Beweis für genau die Arbeit sein, die wir verkaufen: Wenn wir einem Kunden sagen, er solle sein Kontaktformular härten, muss unseres gehärtet sein. Das eigentliche Problem war, dass der einzige Schreibpfad hier — ein öffentlich offener Kontaktendpunkt — keine Authentifizierung hat: Es gibt keine Session und damit serverseitig auch keinen Kontext dazu, wer die Anfrage geschickt hat. Bot-Verkehr, Doppelabsendungen, Header-Injection und eine Flut aus einer einzigen Quelle mussten alle an diesem einen Endpunkt abgefangen werden. Der zweite Punkt war rechtlich: Eine Website, die nach Deutschland verkauft, fällt unter die DSGVO, und die behandelt die IP als personenbezogenes Datum — während die Ratenbegrenzung genau auf dieser IP beruht.
Drei Kontexte in drei Sprachen. Deutschsprachige Besucher sind das primäre Ziel: Sie suchen Impressum, Datenschutz und AGB und erwarten Preistransparenz. Türkischsprachige Besucher schauen auf Leistungsumfang und Referenzen. Englischsprachige Besucher liegen dazwischen. Der vierte Nutzer sind wir selbst: Wir wollen eingehende Anfragen im Postfach sehen, ohne in Spam unterzugehen, und einen Blogbeitrag durch einen Commit ins Repository veröffentlichen statt über ein Panel.
Alleiniger Entwickler. Dreisprachige Informationsarchitektur, Design lokalisierter Routen, Markdown-Content-Pipeline, die Sicherheitsschichten des Kontaktendpunkts, E-Mail-Vorlagen, an die Cookie-Einwilligung gebundene Analytik, Security-Header und Deployment.
Next.js 16 App Router, keine Datenbank. Inhalte kommen aus zwei Quellen: Blogbeiträge aus content/blog/<slug>/<locale>.md, Portfolio- und Referenztexte aus JSON-Dateien unter content/. Beide werden zur Build-Zeit gelesen; die JSON-Seite wird mit Zod validiert, und eine fehlerhafte Sprachdatei fällt über safeParse auf eine leere Menge zurück, statt den gesamten Build zu reißen. Markdown wird mit gray-matter geparst, mit marked zu HTML gewandelt und erst nach zwei Sanitisierungsschichten ausgegeben. Die drei Sprachen (de/tr/en) erscheinen auf jeder Route mit Präfix, und die Pfadnamen sind lokalisiert: /leistungen, /hizmetler und /services sind drei Gesichter derselben Route. Der einzige Schreibpfad ist /api/contact; eine Anfrage durchläuft IP-Extraktion und -Hashing, Ratenbegrenzung, Zod-Validierung, Abweisung von Header-Injection, Idempotenzprüfung, Honeypot, CSRF und Turnstile, bevor sie zwei E-Mails an Resend übergibt. Als dritter Konversionsweg wird ein Cal.com-Buchungs-Embed nur gerendert, wenn die zugehörige Umgebungsvariable gesetzt ist.
In der ersten Version lag das Limit in einer prozessinternen Map. Auf Serverless macht das das Limit bedeutungslos: Jede Instanz trägt ihren eigenen Zähler, wer also genug parallele Anfragen sendet, sieht die Obergrenze nie — und die Map wuchs über die gesamte Lebensdauer des Containers weiter. Der Zähler wanderte zu Upstash Redis, mit einem gleitenden Fenster von 5 Anfragen pro Minute und IP. Schlägt der Upstash-Aufruf fehl, öffnen sich die Tore nicht vollständig: Die Anfrage fällt auf den Speicherzähler zurück, und diese Map wird nun alle fünf Minuten von abgelaufenen Einträgen befreit.
KompromissJede Formularabsendung zahlt jetzt einen zusätzlichen Netzwerkweg zu einem externen Dienst, und die Website hängt von einem dritten Anbieter ab, selbst wenn die eigene Infrastruktur steht. Greift der Rückfall, ist der Schutz kein echter Schutz mehr: Das Limit gilt wieder pro Instanz, verfällt also genau dann in seine schwächste Form, wenn es am dringendsten gebraucht wird. Hinzu kommt mitgeschleppter Ballast — um den Rückfallpfad lebendig zu halten, braucht es einen über die Prozesslebensdauer laufenden Aufräum-Timer und zwei getrennte Codepfade, die gepflegt werden wollen.
Die IP ist hier nur ein Schlüssel: Sie identifiziert denselben Client für Ratenbegrenzung und Idempotenz, sie ist keine aufbewahrenswerte Information. Nach dem Grundsatz der Datenminimierung der DSGVO läuft die IP mit einem prozessweiten Salt durch SHA-256, und in die Maps wandert nur der Digest; der Rohwert verlässt den Anfragekontext nicht. Die zweite Entscheidung ist schärfer: Fehlen sowohl x-real-ip als auch x-forwarded-for, wird die Anfrage mit 400 abgewiesen. Die Alternative, ein gemeinsamer "unknown"-Eimer, hätte einem einzigen Angreifer erlaubt, das Limit für alle ohne diese Header eintreffenden Clients aufzubrauchen.
KompromissLegitime Clients ohne IP-Header — manche Proxy-Ketten, serverseitige Clients, einige Testwerkzeuge — können das Formular überhaupt nicht absenden, und die Fehlermeldung sagt ihnen nicht, was zu tun wäre. Da der Salt nur so lange lebt wie der Prozess, erhält dieselbe IP nach einem Container-Neustart einen neuen Digest, und ihr Zähler im Speicher-Rückfall beginnt von vorn: Der Preis des Datenschutzgewinns ist, dass das Limit ausgerechnet während eines Rückfalls zurückgesetzt werden kann.
Blogbeiträge werden ins Repository committet, die Autoren sind also vertrauenswürdig, und die Ausgabe wird per dangerouslySetInnerHTML gesetzt. Dennoch läuft die marked-Ausgabe zuerst durch einen günstigen Regex-Durchlauf (script-/style-Blöcke, on*-Attribute, javascript:- und data:text/html-URLs) und danach durch DOMPurify. Die Begründung: Der Autor ist nicht das Bedrohungsmodell — ein feindseliger Pull Request oder eine kompromittierte Abhängigkeit, die HTML in Markdown einschleust, hebelt die Annahme vertrauenswürdiger Autoren in einem Schritt aus.
KompromissBei jedem Rendern eines Beitrags laufen zwei getrennte Durchläufe, und isomorphic-dompurify lädt serverseitig jsdom — für eine kleine Content-Pipeline keine leichte Abhängigkeit. Lästiger ist der Preis auf der Autorenseite: DOMPurify entfernt legitimes Markup außerhalb seiner Whitelist (eingebettete Frames, eigene Attribute) stillschweigend. Der Autor erhält keine Warnung, warum sein HTML verschwunden ist, und muss die Ausgabe mit dem Auge prüfen.
Auf der Website gibt es keine Session, also auch keinen serverseitigen Zustand, in dem ein Token zum Vergleich liegen könnte. /api/csrf erzeugt ein zufälliges Token, gibt es im Antwortkörper zurück und schreibt es als SameSite=Strict- und Secure-Cookie; das Formular sendet beides, und der Server prüft die Übereinstimmung mit einem Vergleich in konstanter Zeit. Eine Seite fremden Ursprungs kann dieses Cookie nicht lesen und den Wert im Körper daher nicht fälschen.
KompromissWeil das Cookie für JavaScript lesbar sein muss, kann es nicht HttpOnly sein. Das definiert zugleich die Grenze dieser Schicht: Landet ein XSS auf der Seite, lässt sich das Token direkt auslesen — dieser Mechanismus stoppt also nur CSRF und sagt zu XSS nichts. Außerdem braucht es eine zusätzliche Netzwerkanfrage, bevor das Formular angezeigt werden kann; schlägt sie fehl, kann der Nutzer gar nicht absenden und sieht nur die Meldung, er möge die Seite neu laden.
Eine Persistenzschicht wurde nicht gebaut, weil nichts sie erforderte: Der Inhalt liegt im Repository, der einzige Schreibpfad ist ein E-Mail-Versand. Inhalte im Dateisystem zu halten überlässt Versionsgeschichte und Sprachparität git selbst — fehlt eine der drei Sprachen eines Beitrags, sieht man das im Diff. Upstash wurde gewählt, weil es für einen einzigen Zähler der günstigste Weg ist, verteilten Zustand ohne eigenen Server zu halten; wir speichern keine dauerhaften Daten, nur das gleitende Fenster. Zod ist die einzige Validierungsquelle sowohl für die Kontaktanfrage als auch für die JSON-Inhaltsdateien: Erstere erzeugt zur Laufzeit ein 400, Letztere fängt fehlerhaften Inhalt zur Build-Zeit ab. Resend und React Email sorgen dafür, dass die Anfrage in einem ohnehin geöffneten Postfach landet — besser, als ein Panel zu schreiben, in das sich niemand einloggt.
Zehn HTTP-Header sind definiert: HSTS (zwei Jahre, inklusive Subdomains, preload-fähig), Frame-Verbot, nosniff, Referrer-Policy, Permissions-Policy, X-Permitted-Cross-Domain-Policies, COOP, CORP und CSP. Die CSP ist in der Produktion im Enforce-Modus; object-src ist 'none', base-uri und form-action sind 'self', frame-ancestors ist 'none', in img-src ist das http-Schema geschlossen und upgrade-insecure-requests aktiv — 'unsafe-eval' wird nur in der Entwicklung gewährt. Am Kontaktendpunkt greifen die Schichten der Reihe nach: gesalzener IP-Digest, gleitendes Upstash-Fenster, Zod-Schemavalidierung inklusive Längengrenzen, Abweisung von Feldern mit CR/LF/NUL — was das Header-Smuggling über das an replyTo gereichte E-Mail-Feld schließt — Kurzschluss gegen Doppelabsendung per Idempotenzschlüssel, Honeypot, Double-Submit-CSRF und Turnstile. Turnstile war zuvor "vorübergehend" deaktiviert, sodass jeder Client, der das Token wegließ, die CAPTCHA umgehen konnte; bei gesetztem Schlüssel wird es jetzt strikt erzwungen. Textfelder werden erst von Tags befreit und dann HTML-kodiert. Aufrufe an Dritte sind über AbortController und Promise.race zeitlich begrenzt, Mutationsantworten sind no-store, und Fehlerkörper geben keine internen Details preis. Analytik lädt erst nach erteilter Cookie-Einwilligung.
Animations- und Karussellpakete wurden über optimizePackageImports für Tree Shaking geöffnet — lucide-react, framer-motion, lenis, Turnstile und die beiden Embla-Pakete. Unveränderliche Assets unter /images und /fonts erhalten einen einjährigen immutable-Cache-Header. Die Inhalts-JSONs werden auf Modulebene nach (Verzeichnis, Sprache) memoisiert: Dieselbe Datei wurde je Sprache auf mehr als zehn Routen gelesen. E-Mail-Vorlagen und beide Versände laufen parallel, ohne sequentielles Warten. @next/bundle-analyzer liegt als eigener Befehl im Repo. Für dieses Projekt existiert keine gespeicherte, datierte Lighthouse-Ausgabe, deshalb steht hier kein Seitengeschwindigkeitswert.
Turnstile hätte ich von Anfang an im strikten Modus aktiviert. Eine "vorübergehend" auskommentierte Sicherheitskontrolle bleibt nicht vorübergehend; die primäre Bot-Abwehr des Formulars lief eine Zeit lang, die niemandem auffiel, nach Belieben des Clients. Muss eine Kontrolle gelockert werden, braucht diese Lockerung ein Ablaufdatum. Zweitens würde ich den Rückfall der Ratenbegrenzung neu denken: Die Speicher-Map vermittelt das Gefühl von Schutz, wenn Upstash ausfällt, liefert ihn auf Serverless aber nicht — bei Bergaz Gıda habe ich dasselbe Problem gelöst, indem ich die Limits in die Datenbank schrieb, weil dort ohnehin eine Datenbank existierte. Hier lautet die richtige Antwort vermutlich, im Rückfall fail-closed statt fail-open zu handeln: Das Formular bei nicht erreichbarem Upstash vorübergehend zu schließen ist besser, als es ungeschützt offen zu lassen. Drittens auf der Inhaltsseite: Das Blog-Markdown wird anders als das Zod-geprüfte JSON nicht validiert, Frontmatter-Felder fallen auf leere Zeichenketten zurück. In diesem Portfolio bricht die Schemavalidierung des Projektinhalts im MDX-Frontmatter den Build und zeigt den Fehler sofort; dieselbe Disziplin hätte auch auf der Blogseite gehört.
Sagen Sie mir, was Sie bauen wollen; ich sage Ihnen vorab, wie lange es dauert und wo man anfängt.