Linkvorschau für den ersten Link eines Beitrags
Der Server holt zum ersten Link Titel, Seiten- bzw. Kanalname und ein kleines Vorschaubild (320 px) und speichert das Bild lokal; der Browser lädt nur von uns, Besucher-IPs gehen nie an die verlinkte Seite. YouTube über oEmbed (alle Linkformen kanonisch als watch?v=<id>), alle übrigen Seiten über OpenGraph bzw. <title>. Abruf asynchron in einem Worker, eine Zeile je Link in link_preview, Auffrischen nach 30 Tagen. Der Abruf-Client lässt nur öffentliche IPs auf Port 80/443 zu, geprüft nach DNS-Auflösung und bei jeder Weiterleitung (SSRF). Bestehende Beiträge: kver link-previews. Neue Abhängigkeit golang.org/x/net, damit go 1.26. DEV bekommt ein beschreibbares Volume für die Vorschaubilder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a8f392edba
commit
26b0182391
+567
@@ -0,0 +1,567 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/image/webp"
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/charset"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
// Linkvorschau: Zum ersten Link eines Beitrags holt der Server Titel, Seiten-
|
||||
// bzw. Kanalname und Vorschaubild und speichert das Bild lokal. Der Browser
|
||||
// lädt die Vorschau damit ausschließlich von uns -- Besucher-IPs gehen nie an
|
||||
// die verlinkte Seite (kein Einwilligungsbedarf, CSP bleibt 'self').
|
||||
//
|
||||
// Ablauf: Beim Anlegen/Bearbeiten wird der erste Link kanonisiert (previewKey)
|
||||
// und als entry.link_url gespeichert. requestPreview legt dazu eine Zeile in
|
||||
// link_preview an und reicht sie an den Worker; der holt die Daten im
|
||||
// Hintergrund (das Posten wartet nie). Mehrere Beiträge mit demselben Link
|
||||
// teilen sich eine Zeile und ein Bild. entrySelect joint die fertige Vorschau
|
||||
// an jeden Beitrag.
|
||||
//
|
||||
// Provider: YouTube (oEmbed, liefert auch ohne Cookie-Banner Titel und Kanal)
|
||||
// und allgemein OpenGraph/<title> für alle übrigen Seiten. Weitere Spezialfälle
|
||||
// kommen als eigener Zweig in previewKey/fetchPreview dazu.
|
||||
|
||||
// Preview ist die fertige Vorschau eines Links, wie die API sie ausliefert.
|
||||
type Preview struct {
|
||||
URL string `json:"url"` // Linkziel der Vorschau (kanonisch)
|
||||
Kind string `json:"kind"` // "youtube" | "page"
|
||||
Title string `json:"title"` // Video- bzw. Seitentitel
|
||||
Site string `json:"site"` // Kanal- bzw. Seitenname
|
||||
Thumb string `json:"thumb"` // lokales Bild unter static/media/preview, ggf. ""
|
||||
}
|
||||
|
||||
const (
|
||||
previewRefreshOK = 30 * 24 * 60 * 60 // erfolgreiche Vorschau nach 30 Tagen auffrischen
|
||||
previewRetryFailed = 24 * 60 * 60 // fehlgeschlagene frühestens nach einem Tag erneut
|
||||
maxPreviewURL = 2048
|
||||
maxPageBytes = 1 << 20 // gelesener HTML-Anfang
|
||||
maxThumbBytes = 8 << 20
|
||||
thumbSize = 320 // Zielkante des gespeicherten Vorschaubilds (klein, steht neben dem Text)
|
||||
previewDir = "static/media/preview"
|
||||
)
|
||||
|
||||
// linkPattern findet Links wie linkify im Frontend (format.tsx), damit die
|
||||
// Vorschau exakt zum ersten dort verlinkten Text gehört. \p{Z} und U+FEFF
|
||||
// gleichen Gos ASCII-\s an das Unicode-\s von JavaScript an.
|
||||
var linkPattern = regexp.MustCompile(`https?://[^\s\p{Z}\x{feff}<]+`)
|
||||
|
||||
var youtubeIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{11}$`)
|
||||
|
||||
// previewKey liefert die kanonische URL des ersten Links in content oder "".
|
||||
// YouTube-Links aller Formen werden auf watch?v=<id> gebracht, damit dasselbe
|
||||
// Video nur einmal geholt wird.
|
||||
func previewKey(content string) string {
|
||||
raw := linkPattern.FindString(content)
|
||||
if raw == "" || len(raw) > maxPreviewURL {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return ""
|
||||
}
|
||||
if id := youtubeID(u); id != "" {
|
||||
return "https://www.youtube.com/watch?v=" + id
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// youtubeID erkennt die Video-ID in watch?v=, youtu.be/, /shorts/, /live/,
|
||||
// /embed/ und /v/ auf youtube.com, m./music./www.-Varianten und
|
||||
// youtube-nocookie.com. "" für alles andere (Kanäle, Playlists, ...).
|
||||
func youtubeID(u *url.URL) string {
|
||||
host := strings.ToLower(u.Hostname())
|
||||
for _, p := range []string{"www.", "m.", "music."} {
|
||||
host = strings.TrimPrefix(host, p)
|
||||
}
|
||||
seg := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
var id string
|
||||
switch host {
|
||||
case "youtu.be":
|
||||
id = seg[0]
|
||||
case "youtube.com", "youtube-nocookie.com":
|
||||
switch {
|
||||
case u.Path == "/watch":
|
||||
id = u.Query().Get("v")
|
||||
case len(seg) >= 2 && (seg[0] == "shorts" || seg[0] == "live" || seg[0] == "embed" || seg[0] == "v"):
|
||||
id = seg[1]
|
||||
}
|
||||
}
|
||||
if !youtubeIDPattern.MatchString(id) {
|
||||
return ""
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// requestPreview sorgt dafür, dass zu key eine Vorschau existiert: neue Links
|
||||
// werden angelegt, veraltete oder vor über einem Tag gescheiterte erneut
|
||||
// angestoßen. Nur dann geht der Key an den Worker. Ist dessen Puffer voll,
|
||||
// bleibt die Zeile auf pending und wird beim nächsten Start nachgeholt.
|
||||
func requestPreview(key string) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
res, err := db.Exec(
|
||||
`INSERT INTO link_preview (url) VALUES ($1)
|
||||
ON CONFLICT (url) DO UPDATE SET status = 'pending'
|
||||
WHERE (link_preview.status = 'ok' AND link_preview.fetched_at < $2)
|
||||
OR (link_preview.status = 'none' AND link_preview.fetched_at < $3)`,
|
||||
key, now-previewRefreshOK, now-previewRetryFailed)
|
||||
if err != nil {
|
||||
log.Printf("linkvorschau %s: %v", key, err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case previewCh <- key:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Ein einzelner Worker holt die Vorschauen nacheinander, wie beim Impression-
|
||||
// Worker deckelt das die ausgehenden Requests (Link-Spam erzeugt keine Flut).
|
||||
// Beim Shutdown bricht previewCtx einen laufenden Abruf ab; was noch im Puffer
|
||||
// liegt, bleibt pending und wird beim nächsten Start nachgeholt.
|
||||
var (
|
||||
previewCh = make(chan string, 64)
|
||||
previewDone = make(chan struct{})
|
||||
previewCtx, previewShutdown = context.WithCancel(context.Background())
|
||||
)
|
||||
|
||||
// startPreviewWorker startet den Worker (nach initDB) und holt zuerst die
|
||||
// liegengebliebenen pending-Zeilen nach.
|
||||
func startPreviewWorker() {
|
||||
go func() {
|
||||
defer close(previewDone)
|
||||
for _, key := range pendingPreviews(64) {
|
||||
fetchPreview(previewCtx, key)
|
||||
}
|
||||
for key := range previewCh {
|
||||
fetchPreview(previewCtx, key)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// stopPreviewWorker bricht laufende Abrufe ab und wartet auf den Worker (vor
|
||||
// db.Close rufen).
|
||||
func stopPreviewWorker() {
|
||||
previewShutdown()
|
||||
close(previewCh)
|
||||
<-previewDone
|
||||
}
|
||||
|
||||
func pendingPreviews(limit int) []string {
|
||||
rows, err := db.Query(`SELECT url FROM link_preview WHERE status = 'pending' LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
log.Printf("linkvorschau: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
var keys []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if rows.Scan(&k) == nil {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// fetchPreview holt die Vorschau zu key und speichert das Ergebnis: status ok
|
||||
// mit Daten oder none (Seite ohne verwertbaren Titel, Video privat/gelöscht,
|
||||
// Fehler). Bei abgebrochenem ctx (Shutdown) bleibt die Zeile pending.
|
||||
func fetchPreview(ctx context.Context, key string) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var p Preview
|
||||
var imgs []string // Bildkandidaten, der erste ladbare gewinnt
|
||||
var err error
|
||||
if u, _ := url.Parse(key); u != nil && youtubeID(u) != "" {
|
||||
p, imgs, err = fetchYouTube(ctx, youtubeID(u))
|
||||
} else {
|
||||
p, imgs, err = fetchPage(ctx, key)
|
||||
}
|
||||
if errors.Is(err, context.Canceled) && previewCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
thumb := thumbPath(key)
|
||||
if err != nil {
|
||||
log.Printf("linkvorschau %s: %v", key, err)
|
||||
os.Remove(thumb)
|
||||
db.Exec(`UPDATE link_preview SET status = 'none', title = '', site = '', thumb = '', fetched_at = $2 WHERE url = $1`, key, now)
|
||||
return
|
||||
}
|
||||
stored := false
|
||||
for _, img := range imgs {
|
||||
if err := storeThumb(ctx, img, thumb); err != nil {
|
||||
log.Printf("linkvorschau %s: bild %s: %v", key, img, err)
|
||||
continue
|
||||
}
|
||||
stored = true
|
||||
break
|
||||
}
|
||||
if !stored {
|
||||
os.Remove(thumb)
|
||||
thumb = ""
|
||||
}
|
||||
if previewCtx.Err() != nil {
|
||||
return // Shutdown mitten im Bildabruf: pending lassen, nicht ohne Bild speichern
|
||||
}
|
||||
db.Exec(
|
||||
`UPDATE link_preview SET status = 'ok', kind = $2, title = $3, site = $4, thumb = $5, fetched_at = $6 WHERE url = $1`,
|
||||
key, p.Kind, truncateRunes(p.Title, 300), truncateRunes(p.Site, 100), thumb, now)
|
||||
}
|
||||
|
||||
// thumbPath leitet den Bildnamen aus dem Key ab: deterministisch (Auffrischen
|
||||
// überschreibt dasselbe Bild) und ohne Nutzereingabe im Dateinamen.
|
||||
func thumbPath(key string) string {
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return previewDir + "/" + hex.EncodeToString(sum[:10]) + ".jpg"
|
||||
}
|
||||
|
||||
// YouTube-Endpunkte; Variablen, damit Tests sie auf einen lokalen Server lenken.
|
||||
// mqdefault (320x180) gibt es für jedes Video, in 16:9 ohne schwarze Balken
|
||||
// und genau in der Größe, in der wir es zeigen.
|
||||
var (
|
||||
youtubeOEmbed = "https://www.youtube.com/oembed"
|
||||
youtubeThumbs = []string{"https://i.ytimg.com/vi/%s/mqdefault.jpg"}
|
||||
)
|
||||
|
||||
// fetchYouTube fragt den öffentlichen oEmbed-Endpunkt (ohne API-Key). Das
|
||||
// Bild kommt von der festen Thumbnail-Adresse statt aus thumbnail_url, so
|
||||
// bestimmt allein die geprüfte ID, welche Hosts wir ansprechen.
|
||||
func fetchYouTube(ctx context.Context, id string) (Preview, []string, error) {
|
||||
watch := "https://www.youtube.com/watch?v=" + id
|
||||
resp, err := previewGet(ctx, youtubeOEmbed+"?format=json&url="+url.QueryEscape(watch), "application/json")
|
||||
if err != nil {
|
||||
return Preview{}, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// 401/403: privat oder nicht einbettbar, 404: gelöscht.
|
||||
return Preview{}, nil, fmt.Errorf("oembed: status %d", resp.StatusCode)
|
||||
}
|
||||
var o struct {
|
||||
Title string `json:"title"`
|
||||
AuthorName string `json:"author_name"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&o); err != nil {
|
||||
return Preview{}, nil, fmt.Errorf("oembed: %w", err)
|
||||
}
|
||||
if o.Title == "" {
|
||||
return Preview{}, nil, errors.New("oembed: kein Titel")
|
||||
}
|
||||
imgs := make([]string, len(youtubeThumbs))
|
||||
for i, f := range youtubeThumbs {
|
||||
imgs[i] = fmt.Sprintf(f, id)
|
||||
}
|
||||
return Preview{URL: watch, Kind: "youtube", Title: o.Title, Site: o.AuthorName}, imgs, nil
|
||||
}
|
||||
|
||||
// fetchPage liest den Kopf einer HTML-Seite und nimmt OpenGraph- bzw.
|
||||
// Twitter-Angaben, ersatzweise <title>. Ohne Titel gibt es keine Vorschau.
|
||||
func fetchPage(ctx context.Context, pageURL string) (Preview, []string, error) {
|
||||
resp, err := previewGet(ctx, pageURL, "text/html,application/xhtml+xml")
|
||||
if err != nil {
|
||||
return Preview{}, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return Preview{}, nil, fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if mt, _, _ := mime.ParseMediaType(ct); mt != "text/html" && mt != "application/xhtml+xml" {
|
||||
return Preview{}, nil, fmt.Errorf("kein HTML: %q", ct)
|
||||
}
|
||||
body, err := charset.NewReader(io.LimitReader(resp.Body, maxPageBytes), ct)
|
||||
if err != nil {
|
||||
return Preview{}, nil, err
|
||||
}
|
||||
m := parsePageMeta(body)
|
||||
|
||||
title := firstNonEmpty(m["og:title"], m["twitter:title"], m["title"])
|
||||
if title == "" {
|
||||
return Preview{}, nil, errors.New("kein Titel")
|
||||
}
|
||||
// Nach Redirects gilt die endgültige Adresse als Basis für relative Bild-URLs.
|
||||
base := resp.Request.URL
|
||||
site := firstNonEmpty(m["og:site_name"], strings.TrimPrefix(base.Hostname(), "www."))
|
||||
var imgs []string
|
||||
if ref := firstNonEmpty(m["og:image:secure_url"], m["og:image"], m["og:image:url"], m["twitter:image"], m["twitter:image:src"]); ref != "" {
|
||||
if u, err := base.Parse(ref); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
|
||||
imgs = []string{u.String()}
|
||||
}
|
||||
}
|
||||
return Preview{URL: pageURL, Kind: "page", Title: title, Site: site}, imgs, nil
|
||||
}
|
||||
|
||||
// parsePageMeta sammelt <meta property|name=... content=...> und den
|
||||
// <title>-Text aus dem Dokumentkopf. Kleingeschriebene Schlüssel, jeweils der
|
||||
// erste Treffer zählt; Schluss beim <body> bzw. </head>.
|
||||
func parsePageMeta(r io.Reader) map[string]string {
|
||||
m := map[string]string{}
|
||||
set := func(k, v string) {
|
||||
v = strings.Join(strings.Fields(strings.ToValidUTF8(v, "")), " ")
|
||||
if _, ok := m[k]; !ok && v != "" {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
z := html.NewTokenizer(r)
|
||||
inTitle := false
|
||||
for {
|
||||
switch z.Next() {
|
||||
case html.ErrorToken:
|
||||
return m
|
||||
case html.StartTagToken, html.SelfClosingTagToken:
|
||||
t := z.Token()
|
||||
switch t.Data {
|
||||
case "body":
|
||||
return m
|
||||
case "title":
|
||||
inTitle = true
|
||||
case "meta":
|
||||
var key, content string
|
||||
for _, a := range t.Attr {
|
||||
switch a.Key {
|
||||
case "property", "name":
|
||||
if key == "" {
|
||||
key = strings.ToLower(a.Val)
|
||||
}
|
||||
case "content":
|
||||
content = a.Val
|
||||
}
|
||||
}
|
||||
if key != "" && key != "title" {
|
||||
set(key, content)
|
||||
}
|
||||
}
|
||||
case html.TextToken:
|
||||
if inTitle {
|
||||
set("title", string(z.Text()))
|
||||
}
|
||||
case html.EndTagToken:
|
||||
switch z.Token().Data {
|
||||
case "title":
|
||||
inTitle = false
|
||||
case "head":
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// storeThumb lädt ein Bild, verkleinert es auf thumbSize und speichert es als
|
||||
// JPEG unter name. Das Re-Encode verwirft Metadaten und alles, was kein Bild
|
||||
// ist; animierte GIFs werden zum ersten Frame.
|
||||
func storeThumb(ctx context.Context, imgURL, name string) error {
|
||||
resp, err := previewGet(ctx, imgURL, "image/*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxThumbBytes+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) > maxThumbBytes {
|
||||
return errors.New("bild zu groß")
|
||||
}
|
||||
|
||||
// WebP gezielt dekodieren statt global zu registrieren -- sonst nähme
|
||||
// auch der Upload (storeImage) plötzlich WebP an.
|
||||
decodeConfig, decode := image.DecodeConfig, func(r io.Reader) (image.Image, error) {
|
||||
img, _, err := image.Decode(r)
|
||||
return img, err
|
||||
}
|
||||
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
||||
decodeConfig = func(r io.Reader) (image.Config, string, error) {
|
||||
c, err := webp.DecodeConfig(r)
|
||||
return c, "webp", err
|
||||
}
|
||||
decode = webp.Decode
|
||||
}
|
||||
cfg, _, err := decodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxImagePixels {
|
||||
return fmt.Errorf("bild zu groß: %dx%d", cfg.Width, cfg.Height)
|
||||
}
|
||||
img, err := decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b := img.Bounds()
|
||||
nw, nh := b.Dx(), b.Dy()
|
||||
if nw > thumbSize || nh > thumbSize {
|
||||
if nw > nh {
|
||||
nw, nh = thumbSize, max(nh*thumbSize/nw, 1)
|
||||
} else {
|
||||
nw, nh = max(nw*thumbSize/nh, 1), thumbSize
|
||||
}
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
// Weißer Grund für transparente PNG/WebP, JPEG kennt keinen Alphakanal.
|
||||
xdraw.Draw(dst, dst.Bounds(), image.White, image.Point{}, xdraw.Src)
|
||||
xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, b, xdraw.Over, nil)
|
||||
|
||||
if err := os.MkdirAll(previewDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// Erst in eine Temp-Datei, dann umbenennen: ein Auffrischen ersetzt das
|
||||
// alte Bild atomar, Besucher sehen nie ein halb geschriebenes.
|
||||
tmp, err := os.CreateTemp(previewDir, ".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if err := jpeg.Encode(tmp, dst, &jpeg.Options{Quality: 80}); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), name)
|
||||
}
|
||||
|
||||
// previewGet ist ein GET über previewClient mit ehrlichem User-Agent.
|
||||
func previewGet(ctx context.Context, target, accept string) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "kver-linkvorschau/1.0")
|
||||
req.Header.Set("Accept", accept)
|
||||
req.Header.Set("Accept-Language", "de,en;q=0.5")
|
||||
return previewClient.Do(req)
|
||||
}
|
||||
|
||||
// previewClient ruft beliebige, von Nutzern gepostete URLs ab und muss daher
|
||||
// gegen SSRF gehärtet sein: Wer "http://postgres:5432/" oder eine Adresse im
|
||||
// internen Netz postet, darf unseren Server nicht dorthin schicken. Geprüft
|
||||
// wird in Dialer.Control, also NACH der DNS-Auflösung auf der tatsächlich
|
||||
// angewählten IP -- das deckt auch DNS-Rebinding und jeden Redirect-Schritt ab.
|
||||
// Kein Proxy aus der Umgebung (der würde die Prüfung umgehen).
|
||||
var previewClient = &http.Client{
|
||||
Timeout: 20 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
Control: func(network, address string, _ syscall.RawConn) error {
|
||||
return previewDialGuard(address)
|
||||
},
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
MaxIdleConns: 4,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("zu viele Weiterleitungen")
|
||||
}
|
||||
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
|
||||
return fmt.Errorf("weiterleitung auf %q", req.URL.Scheme)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// previewDialGuard lässt nur öffentliche Adressen auf Port 80/443 zu.
|
||||
// Variable, damit Tests gegen einen lokalen httptest-Server laufen können.
|
||||
var previewDialGuard = func(address string) error {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if port != "80" && port != "443" {
|
||||
return fmt.Errorf("port %s nicht erlaubt", port)
|
||||
}
|
||||
ip, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isPublicIP(ip) {
|
||||
return fmt.Errorf("adresse %s nicht öffentlich", ip)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Nicht-öffentliche Bereiche, die IsGlobalUnicast/IsPrivate nicht abdecken.
|
||||
var reservedNets = []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/8"), // "dieses Netz"
|
||||
netip.MustParsePrefix("100.64.0.0/10"), // Carrier-Grade-NAT
|
||||
netip.MustParsePrefix("192.0.0.0/24"), // IETF-Protokollzuweisungen
|
||||
netip.MustParsePrefix("192.0.2.0/24"), // Dokumentation
|
||||
netip.MustParsePrefix("198.18.0.0/15"), // Benchmarking
|
||||
netip.MustParsePrefix("198.51.100.0/24"), // Dokumentation
|
||||
netip.MustParsePrefix("203.0.113.0/24"), // Dokumentation
|
||||
netip.MustParsePrefix("240.0.0.0/4"), // reserviert, inkl. Broadcast
|
||||
netip.MustParsePrefix("64:ff9b::/96"), // NAT64: bildet IPv4 inkl. interner Netze ab
|
||||
netip.MustParsePrefix("64:ff9b:1::/48"), // lokales NAT64
|
||||
netip.MustParsePrefix("2001:db8::/32"), // Dokumentation
|
||||
netip.MustParsePrefix("2002::/16"), // 6to4: kann interne IPv4 einbetten
|
||||
}
|
||||
|
||||
func isPublicIP(ip netip.Addr) bool {
|
||||
ip = ip.Unmap()
|
||||
if !ip.IsGlobalUnicast() || ip.IsPrivate() {
|
||||
return false
|
||||
}
|
||||
for _, p := range reservedNets {
|
||||
if p.Contains(ip) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user