alangu.de fuer die Gebaerdensprach-Einbindung freigeben
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 1m33s

Der Player wird als iframe eingebunden, aber nicht als rohes HTML aus dem
Textfeld: Die Anwendung setzt die Attribute, im Admin steht nur die
Adresse. Damit laesst sich ueber das Feld nichts anderes einschleusen.

Freigegeben ist alangu.de samt Unterdomains, sonst nichts. Die Pruefung
haengt am Hostnamen, nicht an einem Textvergleich - getestet mit
boese-alangu.de.angreifer.example und evil.example/?a=alangu.de, beide
werden abgewiesen, ebenso http statt https.

Vom uebergebenen Einbettungscode weggelassen: accelerometer und
gyroscope. Ein Videoplayer braucht keine Bewegungssensoren.

Ausserdem: Die Gebaerdensprach-Seite erscheint jetzt auch ohne Text,
wenn eine Videoadresse hinterlegt ist - dort ist das Video der Inhalt.
Fusszeile und Seite entscheiden das ueber dieselbe Funktion, sonst gaebe
es eine erreichbare Seite ohne Link.

Geprueft am echten Player: laedt mit HTTP 200 samt CSS, JS und hls.js,
axe-core meldet in hell und dunkel keine Verstoesse, der Rahmen ist per
Tastatur erreichbar, und die Fusszeile verlinkt die Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Weapie 2026-09-08 16:28:41 +02:00
parent 5aa8880190
commit 0134dd6db4
4 changed files with 108 additions and 34 deletions

View File

@ -3,7 +3,13 @@ import { notFound } from "next/navigation"
import { PublicHeader } from "@/components/public-header" import { PublicHeader } from "@/components/public-header"
import { SiteFooter } from "@/components/site-footer" import { SiteFooter } from "@/components/site-footer"
import { markdownToHtml } from "@/lib/markdown" import { markdownToHtml } from "@/lib/markdown"
import { SITE_PAGE_META, SitePageKey, isSafeMediaUrl } from "@/lib/site-pages" import {
SITE_PAGE_META,
SitePageKey,
hasSitePageContent,
isAllowedEmbedUrl,
isSafeMediaUrl,
} from "@/lib/site-pages"
import { readSitePages } from "@/lib/site-pages.server" import { readSitePages } from "@/lib/site-pages.server"
/** /**
@ -17,17 +23,20 @@ import { readSitePages } from "@/lib/site-pages.server"
export async function LegalPage({ page }: { page: SitePageKey }) { export async function LegalPage({ page }: { page: SitePageKey }) {
const pages = await readSitePages() const pages = await readSitePages()
const content = pages[page].trim() const content = pages[page].trim()
if (!content) notFound()
const html = await markdownToHtml(content)
const meta = SITE_PAGE_META[page] const meta = SITE_PAGE_META[page]
// Zweite Prüfung vor der Ausgabe: Was in der Datenbank steht, könnte aus // Auf der Gebärdensprach-Seite ist das Video der eigentliche Inhalt. Sie
// einer Zeit vor der Prüfung in der API stammen. // erscheint deshalb auch dann, wenn nur eine Videoadresse hinterlegt ist –
const video = isSafeMediaUrl(pages.signLanguageVideo) // ein zusätzlicher Text ist willkommen, aber keine Bedingung.
? pages.signLanguageVideo.trim() const video =
: "" page === "signLanguage" && isSafeMediaUrl(pages.signLanguageVideo)
? pages.signLanguageVideo.trim()
: ""
if (!hasSitePageContent(pages, page)) notFound()
const html = content ? await markdownToHtml(content) : ""
const captions = isSafeMediaUrl(pages.signLanguageCaptions) const captions = isSafeMediaUrl(pages.signLanguageCaptions)
? pages.signLanguageCaptions.trim() ? pages.signLanguageCaptions.trim()
: "" : ""
@ -46,24 +55,39 @@ export async function LegalPage({ page }: { page: SitePageKey }) {
werden eingebunden, sobald eine WebVTT-Datei hinterlegt ist. */} werden eingebunden, sobald eine WebVTT-Datei hinterlegt ist. */}
{page === "signLanguage" && video && ( {page === "signLanguage" && video && (
<figure className="mb-8"> <figure className="mb-8">
<video {isAllowedEmbedUrl(video) ? (
controls // Player eines zugelassenen Anbieters. Die Attribute setzt die
preload="metadata" // Anwendung, nicht der Text im Admin – so lässt sich über das
className="w-full rounded-lg border bg-muted" // Adressfeld nichts anderes einschleusen.
> <div className="aspect-video w-full overflow-hidden rounded-lg border bg-muted">
<source src={video} /> <iframe
{captions && ( src={video}
<track title="Video in Deutscher Gebärdensprache mit Erläuterungen zu diesem Portal"
src={captions} allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
kind="captions" allowFullScreen
srcLang="de" className="size-full border-0"
label="Deutsch"
default
/> />
)} </div>
Ihr Browser kann dieses Video nicht abspielen.{" "} ) : (
<a href={video}>Video herunterladen</a> <video
</video> controls
preload="metadata"
className="w-full rounded-lg border bg-muted"
>
<source src={video} />
{captions && (
<track
src={captions}
kind="captions"
srcLang="de"
label="Deutsch"
default
/>
)}
Ihr Browser kann dieses Video nicht abspielen.{" "}
<a href={video}>Video herunterladen</a>
</video>
)}
<figcaption className="mt-2 text-sm text-muted-foreground"> <figcaption className="mt-2 text-sm text-muted-foreground">
Erläuterungen zu diesem Portal in Deutscher Gebärdensprache Erläuterungen zu diesem Portal in Deutscher Gebärdensprache
</figcaption> </figcaption>

View File

@ -1,7 +1,7 @@
import Link from "next/link" import Link from "next/link"
import { appName } from "@/lib/app-config" import { appName } from "@/lib/app-config"
import { SITE_PAGES, SITE_PAGE_META } from "@/lib/site-pages" import { SITE_PAGES, SITE_PAGE_META, hasSitePageContent } from "@/lib/site-pages"
import { readSitePages } from "@/lib/site-pages.server" import { readSitePages } from "@/lib/site-pages.server"
/** /**
@ -14,7 +14,7 @@ import { readSitePages } from "@/lib/site-pages.server"
*/ */
export async function SiteFooter() { export async function SiteFooter() {
const pages = await readSitePages() const pages = await readSitePages()
const links = SITE_PAGES.filter((key) => pages[key].trim()) const links = SITE_PAGES.filter((key) => hasSitePageContent(pages, key))
return ( return (
<footer className="mt-12 border-t"> <footer className="mt-12 border-t">

View File

@ -276,8 +276,10 @@ export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
placeholder="/videos/gebaerdensprache.mp4" placeholder="/videos/gebaerdensprache.mp4"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Eigener Server (beginnt mit /) oder https. Kein Videodatei auf dem eigenen Server (beginnt mit /) oder
YouTube oder Vimeo – das überträgt Besucherdaten an Player-Adresse eines freigegebenen Anbieters. Derzeit
freigegeben: alangu.de. Andere Videoportale sind
bewusst gesperrt – sie übertragen Besucherdaten an
Dritte. Dritte.
</p> </p>
</div> </div>
@ -298,8 +300,9 @@ export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
placeholder="/videos/gebaerdensprache.vtt" placeholder="/videos/gebaerdensprache.vtt"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
WebVTT-Datei. Nötig, sobald das Video gesprochenen Ton WebVTT-Datei, nur bei einer eigenen Videodatei nötig
enthält (WCAG 1.2.2). (WCAG 1.2.2). Ein Player-Anbieter bringt seine
Untertitel selbst mit.
</p> </p>
</div> </div>
</div> </div>

View File

@ -79,7 +79,54 @@ export const EMPTY_SITE_PAGES: SitePagesContent = {
} }
/** /**
* Prüft eine Medienadresse. Zugelassen sind nur eine Adresse auf dem eigenen * Anbieter, deren Player als eingebetteter Rahmen zugelassen ist.
*
* Bewusst eine kurze Liste statt einer allgemeinen Freigabe für iframes: Ein
* eingebetteter Rahmen lädt beim Seitenaufruf von einem fremden Server und
* überträgt dabei IP-Adresse und Browserkennung der Besucherinnen und
* Besucher. Das ist bei einem Gebärdensprach-Dienst vertretbar, für den ein
* Auftragsverarbeitungsvertrag besteht – bei einem Videoportal wie YouTube
* wäre es das nicht.
*
* Eingetragen wird die Domain; Unterdomains sind eingeschlossen.
*/
export const EMBED_HOSTS = ["alangu.de"] as const
/**
* Hat eine Seite Inhalt und wird deshalb ausgeliefert und verlinkt?
*
* Auf der Gebärdensprach-Seite ist das Video der eigentliche Inhalt; sie zählt
* daher auch ohne Text als gepflegt. Fußzeile und Seite selbst müssen dieselbe
* Regel verwenden, sonst gibt es eine erreichbare Seite ohne Link oder einen
* Link ins Leere.
*/
export function hasSitePageContent(
pages: SitePagesContent,
key: SitePageKey
): boolean {
if (pages[key].trim()) return true
return key === "signLanguage" && pages.signLanguageVideo.trim().length > 0
}
/** Gehört die Adresse zu einem zugelassenen Einbettungs-Anbieter? */
export function isAllowedEmbedUrl(value: string) {
const wert = value.trim()
if (!wert) return false
try {
const url = new URL(wert)
if (url.protocol !== "https:") return false
return EMBED_HOSTS.some(
(host) => url.hostname === host || url.hostname.endsWith(`.${host}`)
)
} catch {
return false
}
}
/**
* Prüft eine Medienadresse. Zugelassen sind eine Adresse auf dem eigenen
* Server (mit / beginnend) und https. Damit sind `javascript:` und `data:` * Server (mit / beginnend) und https. Damit sind `javascript:` und `data:`
* ausgeschlossen, bevor der Wert in ein src-Attribut gelangt. * ausgeschlossen, bevor der Wert in ein src-Attribut gelangt.
*/ */