Files
verdande/internal/safedial/safedial.go
T
Kristian WindandClaude Opus 5 4aa8bd6a40
Release / Build and publish image (push) Failing after 9m54s
fix(sikkerhed): resten af gennemgangen — SSRF, tre ubegrænsede veje, og fire hemmeligheder
**H2 — SSRF gennem AI-udbyderens adresse.** `base_url` blev gemt uden kontrol og
hentet med en almindelig klient, og udbyderens svar blev givet tilbage ordret.
Det var en fungerende skanner af det interne net for enhver bruger: peg den på en
vært, læs de første tre hundrede bytes af det, der svarede. På en homelab er det
det meste af, hvad der er værd at nå.

Nu gennem `safedial`, og svarets krop går i loggen frem for i svaret. Operatøren
har stadig brug for beskeden — for en lokal model er den som regel selve
problemet — så den er der, bare kun for den, der kører instansen.

**M2 — Web Push havde tre.** Endepunktet blev gemt ukontrolleret og POSTet til på
en tidsplan, den, der ringer, selv vælger: sæt en påmindelse et minut ude, og
serveren henter adressen, hvert minut. Upserten flyttede desuden ejerskabet med
`user_id = excluded.user_id`, så enhver, der kendte en andens endepunkt, kunne
overtage det — offerets telefon ville få angriberens beskeder og ingen af sine
egne. Og `DeletePushSubscription` havde ingen ejer-kontrol.

**H3 — tre veje uden loft.** FTS-udtrykket byggede omkring halvtreds bytes pr.
ord uden grænse; en søgning på en kvart megabyte blev til to megabyte
MATCH-udtryk og seksogtyve sekunder inde i SQLite, mens den holdt på én af kun
fire til otte forbindelser. Nogle få af dem samtidig, og databasen er utilgængelig
for alle på instansen, logins inklusive. `filterquery.Parse` er kvadratisk —
halvtreds tusind led er syv sekunders CPU og seksogfyrre gigabyte allokering i én
forespørgsel — og havde hverken loft på antal eller på dybde. Og noteimporten
læste hver fil i arkivet uden grænse: 64 MB komprimeret kan blive til
seksogtres gigabyte skrevet ned på den disk, databasen ligger på.

**M1 — `comment_id` blev taget på tro.** Rettighedskontrollen så på opgaven i
adressen, mens filen blev hængt på den kommentar, formularen bad om. En viewer på
et delt projekt kan liste dets kommentarer, så hun kunne læse et id og lægge en
fil under en andens kommentar i et projekt, hun kun må læse.

**M4 — fire hemmeligheder lå i klartekst i hver sikkerhedskopi.** TOTP-koden og
AI-nøglen forsegles nu; begge konverterer sig selv næste gang de skrives.
Kalenderfeedets og mailadressens tokens gør ikke — de slås op på deres præcise
værdi og vises i fladen mere end én gang, hvilket en hash ikke kan. Det står nu
skrevet i `SECURITY.md`, som påstod det modsatte. At rette det betyder, at
adressen kun kan vises én gang, og det er en beslutning om, hvordan funktionen
virker.

Og en prøve på, at politikken overhovedet bliver sendt. Det var den, der gjorde
XSS-en i nat til indsat HTML frem for kørt script, og der var ingen prøve, der
rørte den — en foranstaltning, ingen prøve rører, kan forsvinde i en oprydning,
uden at nogen opdager det. Målt på script-src alene: `style-src 'unsafe-inline'`
står der med vilje, og en indsat stil kan gøre en side grim, ikke køre kode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 08:39:52 +02:00

137 lines
4.9 KiB
Go

// Package safedial refuses to open a connection to this machine or to the
// network it sits on.
//
// The problem it solves is the same one three times: somewhere a user supplies a
// URL or a host, and the server fetches it. An AI provider's base address, a push
// endpoint, an IMAP host, a beacon collector. Each of them is a feature, and each
// of them is also a way to ask the server to reach something the caller cannot —
// the panel next door, a metadata endpoint, a database on the same bridge — and
// to report back whether it answered. On a homelab that is the whole point of the
// box being where it is.
//
// # Why the check is on the address and not on the URL
//
// Parsing the URL and refusing "localhost" or "127.0.0.1" is the obvious version
// and it does not work: a name resolves to whatever its owner says, so
// `evil.example.com` can answer 127.0.0.1 — and can answer differently the second
// time, after a parse-time check has passed. That is DNS rebinding, and the only
// place it can be caught is after resolution and before the connection, which is
// exactly where net.Dialer's Control hook runs.
//
// # What is refused
//
// Loopback, link-local (including 169.254.169.254, which is where cloud metadata
// lives), the private ranges, unique-local IPv6, and the unspecified address.
// Everything else is allowed: this is not an allowlist of the internet, it is a
// wall around the inside.
package safedial
import (
"fmt"
"net"
"net/http"
"net/url"
"syscall"
"time"
)
// ErrPrivate is returned when a connection was refused for where it pointed. It
// deliberately does not say which address it resolved to: the answer is itself
// the thing an attacker is fishing for.
type ErrPrivate struct{ Host string }
func (e *ErrPrivate) Error() string {
return fmt.Sprintf("refusing to connect to %s: it resolves to an address on this machine or its private network", e.Host)
}
// Blocked reports whether an address must not be dialled.
func Blocked(ip net.IP) bool {
return ip == nil ||
ip.IsLoopback() ||
ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast() ||
ip.IsPrivate()
}
// Control is a net.Dialer Control function that refuses a private destination.
//
// It runs after the name has been resolved and before the socket is connected,
// which is the only moment where the address that will actually be used is known.
func Control(network, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if Blocked(ip) {
return &ErrPrivate{Host: host}
}
return nil
}
// Dialer is a net.Dialer that will not reach inside.
func Dialer(timeout time.Duration) *net.Dialer {
return &net.Dialer{Timeout: timeout, Control: Control}
}
// Client is an http.Client for fetching something a user named.
//
// Redirects are followed but capped, and every hop goes through the same dialer —
// a redirect to 127.0.0.1 is the oldest way around a check that only looked at the
// URL somebody typed.
func Client(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
Transport: &http.Transport{
DialContext: Dialer(timeout).DialContext,
TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return nil
},
}
}
// AllowPrivate is the same client without the wall, for a destination the
// operator configured rather than a user — the panel, and nothing else. Named so
// that using it is a decision somebody wrote down.
func AllowPrivate(timeout time.Duration) *http.Client {
return &http.Client{Timeout: timeout}
}
// CheckURL reports why an address must not be fetched, or "" if it may be.
//
// A convenience for saying no at the moment somebody types an address, rather
// than later when a request quietly fails. It is NOT the protection — a name can
// resolve one way now and another way when it is dialled, which is why Control
// exists and runs every time. Anything this rejects is certainly wrong; anything
// it accepts is merely not obviously wrong.
func CheckURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return "that is not a URL"
}
host := u.Hostname()
if ip := net.ParseIP(host); ip != nil && Blocked(ip) {
return "that address is on this machine or its private network"
}
// A bare name is looked up once so an obvious "localhost" is caught here. A
// failure to resolve is not an error: the host may simply not exist yet, and
// refusing to save an address because DNS was slow would be its own bug.
if ips, err := net.LookupIP(host); err == nil {
for _, ip := range ips {
if Blocked(ip) {
return "that address is on this machine or its private network"
}
}
}
return ""
}