fix: harden systemd unit, mail headers, list downloads and PID file
Security Scan / Go Vulnerability Check (pull_request) Successful in 1m52s
PR Tests / Format, Vet, Build & Test (pull_request) Successful in 2m4s

This commit is contained in:
Patrick Asmus
2026-10-11 14:00:42 +02:00
parent edc3ae626f
commit c16fbf0ef7
24 changed files with 800 additions and 35 deletions
+4
View File
@@ -77,6 +77,10 @@ EXTERNAL_BLOCKLIST_AUTO_UNBAN=true
EXTERNAL_BLOCKLIST_NOTIFY=false # Bei großen Listen auf false lassen
EXTERNAL_BLOCKLIST_CACHE_DIR="/var/lib/adguard-shield/external-blocklist"
# --- Externe Listen: Transport ---
# Externe White-/Blocklisten werden standardmäßig nur per https:// geladen.
ALLOW_INSECURE_LIST_URLS=false # true = auch http:// erlauben (unsicher, Listen können manipuliert werden)
# --- Progressive Sperren (Recidive) ---
# Wiederholungstäter werden stufenweise länger gesperrt
PROGRESSIVE_BAN_ENABLED=true
+22 -5
View File
@@ -15,16 +15,33 @@ Restart=on-failure
RestartSec=30
# Sicherheits-Hardening
# Der Daemon läuft als root, aber nur mit den Capabilities, die er wirklich
# nutzt. Log, State-DB, Listen-Caches, GeoIP-Downloads und PID-Datei gehören
# root, daher sind CAP_DAC_OVERRIDE, CAP_DAC_READ_SEARCH, CAP_FOWNER und
# CAP_CHOWN nicht nötig. Der Daemon wechselt keine Benutzer (kein CAP_SETUID/
# CAP_SETGID) und signalisiert keine fremden Prozesse (kein CAP_KILL).
# E-Mail-Reports laufen per Cron (report-send) außerhalb dieses Service;
# NoNewPrivileges betrifft sendmail/postdrop daher nicht.
ProtectSystem=full
ReadWritePaths=/var/log /var/lib/adguard-shield /var/run /opt/adguard-shield/geoip
ProtectHome=true
NoNewPrivileges=false
PrivateTmp=true
NoNewPrivileges=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictRealtime=true
ProtectClock=true
ProtectControlGroups=true
ProtectKernelLogs=true
SystemCallArchitectures=native
# Bewusst nicht gesetzt: ProtectKernelTunables (iptables/ipset können
# /proc/sys/net beschreiben) und ProtectKernelModules (Netfilter-Module).
# iptables benötigt CAP_NET_ADMIN + CAP_NET_RAW
# Weitere Capabilities für Dateizugriff, Signale und Prozessverwaltung
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_KILL CAP_SETUID CAP_SETGID CAP_CHOWN
# CAP_NET_ADMIN: iptables, ip6tables, ipset
# CAP_NET_RAW: iptables-legacy nutzt einen Raw-Socket
# CAP_NET_BIND_SERVICE: nur für WEB_LISTEN mit einem Port unter 1024
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE
# Logging
StandardOutput=journal
-7
View File
@@ -529,13 +529,6 @@ func flushSource(ctx context.Context, d *daemon.Daemon, source string) error {
return nil
}
func writePID(path string) error {
if path == "" {
return nil
}
return os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())+"\n"), 0644)
}
func commandContext(d *daemon.Daemon, daemonMode bool) (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(context.Background())
signals := make(chan os.Signal, 1)
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"fmt"
"os"
"strconv"
)
// writePID schreibt die PID des laufenden Prozesses nach path. Da der Daemon
// als root läuft und PID_FILE konfigurierbar ist, folgt das Öffnen keinem
// Symlink (O_NOFOLLOW, siehe pidfile_unix.go) und das Ziel muss eine reguläre
// Datei sein. Die Datei ist bewusst für alle lesbar (0644).
func writePID(path string) error {
if path == "" {
return nil
}
if fi, err := os.Lstat(path); err == nil && !fi.Mode().IsRegular() {
return fmt.Errorf("PID-Datei %s ist keine reguläre Datei (%s)", path, fi.Mode().Type())
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC|pidOpenFlags, 0644)
if err != nil {
return fmt.Errorf("PID-Datei %s kann nicht geschrieben werden: %w", path, err)
}
// Erneut am geöffneten Handle prüfen: Zwischen Lstat und OpenFile könnte
// die Datei ersetzt worden sein.
if fi, err := f.Stat(); err != nil || !fi.Mode().IsRegular() {
_ = f.Close()
if err == nil {
err = fmt.Errorf("keine reguläre Datei (%s)", fi.Mode().Type())
}
return fmt.Errorf("PID-Datei %s: %w", path, err)
}
if _, err := f.WriteString(strconv.Itoa(os.Getpid()) + "\n"); err != nil {
_ = f.Close()
return fmt.Errorf("PID-Datei %s: %w", path, err)
}
return f.Close()
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !unix
package main
// Auf Nicht-Unix-Systemen (z. B. Windows-Entwicklungsbuilds) gibt es kein
// O_NOFOLLOW; dort bleibt nur die Prüfung auf eine reguläre Datei.
const pidOpenFlags = 0
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
)
func TestWritePIDCreatesFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "adguard-shield.pid")
if err := os.WriteFile(path, []byte("999999999\nalter Inhalt\n"), 0600); err != nil {
t.Fatal(err)
}
if err := writePID(path); err != nil {
t.Fatalf("writePID: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if got, want := strings.TrimSpace(string(data)), strconv.Itoa(os.Getpid()); got != want {
t.Fatalf("PID-Datei = %q, want %q", got, want)
}
}
func TestWritePIDEmptyPath(t *testing.T) {
if err := writePID(""); err != nil {
t.Fatalf("writePID(\"\") = %v", err)
}
}
func TestWritePIDRejectsDirectory(t *testing.T) {
if err := writePID(t.TempDir()); err == nil {
t.Fatal("writePID akzeptiert ein Verzeichnis")
}
}
func TestWritePIDRejectsSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Symlinks/O_NOFOLLOW werden unter Windows nicht geprüft")
}
dir := t.TempDir()
target := filepath.Join(dir, "ziel")
if err := os.WriteFile(target, []byte("unverändert\n"), 0600); err != nil {
t.Fatal(err)
}
link := filepath.Join(dir, "adguard-shield.pid")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if err := writePID(link); err == nil {
t.Fatal("writePID folgt einem Symlink")
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(data) != "unverändert\n" {
t.Fatalf("Symlink-Ziel wurde verändert: %q", data)
}
// Auch ein hängender Symlink darf keine Datei am Ziel anlegen.
dangling := filepath.Join(dir, "haengend.pid")
missing := filepath.Join(dir, "fehlt")
if err := os.Symlink(missing, dangling); err != nil {
t.Fatal(err)
}
if err := writePID(dangling); err == nil {
t.Fatal("writePID folgt einem hängenden Symlink")
}
if _, err := os.Stat(missing); !os.IsNotExist(err) {
t.Fatalf("Ziel des hängenden Symlinks wurde angelegt: %v", err)
}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build unix
package main
import "syscall"
// O_NOFOLLOW: ein Symlink als letztes Pfadelement führt zu ELOOP.
// O_NONBLOCK: ein untergeschobenes FIFO blockiert das Öffnen nicht.
const pidOpenFlags = syscall.O_NOFOLLOW | syscall.O_NONBLOCK
+19
View File
@@ -381,6 +381,25 @@ Nach einer Standardinstallation sieht die Struktur so aus:
└── adguard-shield-report # Cron-Job für Reports (optional)
```
### Rechte des systemd-Service
Der Daemon läuft als root, die Unit beschränkt die Capabilities aber auf das, was er tatsächlich braucht:
| Capability | Wofür |
|---|---|
| `CAP_NET_ADMIN` | `iptables`, `ip6tables`, `ipset` (Chain, Regeln, Sets) |
| `CAP_NET_RAW` | `iptables-legacy` öffnet einen Raw-Socket |
| `CAP_NET_BIND_SERVICE` | nur nötig, wenn `WEB_LISTEN` einen Port unter 1024 nutzt |
Alles andere (u. a. `CAP_DAC_OVERRIDE`, `CAP_SETUID`, `CAP_KILL`) ist entfernt. Zusätzlich gelten `NoNewPrivileges=true`, `RestrictSUIDSGID`, `LockPersonality`, `RestrictRealtime`, `ProtectClock`, `ProtectControlGroups`, `ProtectKernelLogs` und `SystemCallArchitectures=native`. `ProtectKernelTunables` und `ProtectKernelModules` sind bewusst nicht gesetzt, damit iptables/ipset Netfilter-Einstellungen und -Module wie bisher nutzen können.
Ein Betrieb als eigener Benutzer (`User=`) ist nicht vorgesehen: Firewall-Befehle, `/var/run`, `/var/log` und die Installation unter `/opt` setzen root voraus, und eine Umstellung würde bestehende Dateirechte ändern.
Folgen für die Praxis:
- **Dateirechte:** Ohne `CAP_DAC_OVERRIDE` gelten auch für root die normalen Dateirechte. Alle Pfade, die der Daemon schreibt (`LOG_FILE`, `STATE_DIR`, Listen-Caches, `geoip/`, `PID_FILE`), müssen root gehören oder für root beschreibbar sein; gelesene Dateien (Konfiguration, `GEOIP_MMDB_PATH`) müssen für root lesbar sein. Bei der Standardinstallation ist das der Fall.
- **E-Mail-Reports:** Reports werden nicht vom Service, sondern per Cron (`report-send`) bzw. per CLI versendet und unterliegen diesen Einschränkungen nicht. `NoNewPrivileges` würde z. B. den setgid-Helfer `postdrop` von Postfix ausbremsen – das betrifft den Report-Versand daher nicht.
## Hintergrundjobs im Daemon
Es gibt in der Go-Version keine separaten Worker-Skripte mehr. Diese Aufgaben laufen als Goroutines im Daemon:
+20 -1
View File
@@ -38,6 +38,7 @@ Ungültige Werte führen nicht zum Abbruch, damit der Schutz weiterläuft. Statt
| Ungültige Ports | `BLOCKED_PORTS="53 abc 70000"` (ungültige Einträge werden ignoriert; ist kein Port gültig, gilt `53 443 853`) |
| Ungültige Auswahlwerte | `GEOIP_MODE`, `FIREWALL_MODE`, `LOG_LEVEL`, `NOTIFY_TYPE`, `REPORT_FORMAT`, `REPORT_INTERVAL` |
| Ungültige Web-Adresse/-Pfade | `WEB_LISTEN="8443"`, `WEB_BASE_PATH="/../x"`, nur `WEB_TLS_CERT` ohne `WEB_TLS_KEY` |
| Listen-URLs ohne `https://` | `EXTERNAL_BLOCKLIST_URLS="http://..."` oder `file://...` (die Quelle wird nicht geladen, siehe [Externe Listen: Transport und Größe](#externe-listen-transport-und-größe)) |
Veraltete Schlüssel der Shell-Version wie `BAN_HISTORY_FILE` werden ignoriert und mit einem eigenen Hinweis gemeldet.
@@ -99,7 +100,7 @@ AdGuard Shield ruft intern diesen Endpunkt ab:
/control/querylog?limit=<API_QUERY_LIMIT>&response_status=all
```
**Hinweis:** Standardmäßig (`ADGUARD_TLS_INSECURE=false`) prüft AdGuard Shield das TLS-Zertifikat der AdGuard-Home-API. Nutzt AdGuard Home ein selbstsigniertes Zertifikat, schlägt die Verbindung fehl. Abhilfe: gültiges Zertifikat, die API lokal per `http://127.0.0.1:<port>` ansprechen oder `ADGUARD_TLS_INSECURE=true` setzen. Letzteres erlaubt Man-in-the-Middle-Angriffe auf die API-Verbindung (Zugangsdaten und Querylog) und sollte nur in vertrauenswürdigen Netzen genutzt werden. Benachrichtigungen prüfen Zertifikate standardmäßig; nur mit `NOTIFY_TLS_INSECURE=true` werden für das Benachrichtigungsziel auch selbstsignierte Zertifikate akzeptiert. AbuseIPDB, externe Listen und der MaxMind-Download prüfen Zertifikate immer.
**Hinweis:** Standardmäßig (`ADGUARD_TLS_INSECURE=false`) prüft AdGuard Shield das TLS-Zertifikat der AdGuard-Home-API. Nutzt AdGuard Home ein selbstsigniertes Zertifikat, schlägt die Verbindung fehl. Abhilfe: gültiges Zertifikat, die API lokal per `http://127.0.0.1:<port>` ansprechen oder `ADGUARD_TLS_INSECURE=true` setzen. Letzteres erlaubt Man-in-the-Middle-Angriffe auf die API-Verbindung (Zugangsdaten und Querylog) und sollte nur in vertrauenswürdigen Netzen genutzt werden. Benachrichtigungen prüfen Zertifikate standardmäßig; nur mit `NOTIFY_TLS_INSECURE=true` werden für das Benachrichtigungsziel auch selbstsignierte Zertifikate akzeptiert. AbuseIPDB, externe Listen und der MaxMind-Download prüfen Zertifikate immer. Externe Listen werden außerdem nur per `https://` geladen (siehe [Externe Listen: Transport und Größe](#externe-listen-transport-und-größe)).
---
@@ -559,6 +560,24 @@ EXTERNAL_BLOCKLIST_NOTIFY=false
---
## Externe Listen: Transport und Größe
Die externe Whitelist und die externe Blocklist fließen direkt in Firewall-Entscheidungen ein. Deshalb gelten für beide dieselben Regeln beim Download:
| Parameter | Standard | Beschreibung |
|---|---|---|
| `ALLOW_INSECURE_LIST_URLS` | `false` | `true` = Listen auch unverschlüsselt per `http://` laden (unsicher) |
- Erlaubt sind nur `https://`-URLs mit Host. `http://` wird nur mit `ALLOW_INSECURE_LIST_URLS=true` akzeptiert, andere Schemata wie `file://` oder `ftp://` nie.
- Eine abgelehnte URL meldet schon beim Start eine Konfigurationswarnung und bei jedem Sync eine Download-Warnung. Sie zählt als fehlgeschlagene Quelle: Die externe Whitelist bleibt dann unverändert, der Auto-Unban der Blocklist wird übersprungen. Ein vorhandener Cache dieser Quelle wird bewusst nicht genutzt, da er aus einem unverschlüsselten Download stammen kann.
- Mit `ALLOW_INSECURE_LIST_URLS=true` wird für jede `http://`-Quelle beim Start und beim ersten Download eine Warnung geschrieben.
- Weiterleitungen müssen dieselben Regeln erfüllen. Eine Weiterleitung von `https://` auf `http://` wird ohne `ALLOW_INSECURE_LIST_URLS=true` abgelehnt; dann wird wie bei anderen Download-Fehlern der Cache der Quelle verwendet.
- Eine Liste darf höchstens 50 MB groß sein. Größere Downloads werden verworfen statt abgeschnitten: Der Cache bleibt unverändert und wird verwendet (Warnung im Log); ohne Cache gilt die Quelle als fehlgeschlagen.
**Hinweis für Updates:** Bis v1.1.x wurden `http://`-Listen ohne Rückfrage geladen. Nach dem Update werden sie abgelehnt, im Log steht z.B. `blocklist Download Warnung Quelle #1 (http://example.com): unverschlüsseltes http:// ist nicht erlaubt ...`. Abhilfe: die URL in `EXTERNAL_WHITELIST_URLS` bzw. `EXTERNAL_BLOCKLIST_URLS` auf `https://` umstellen (die meisten Anbieter unterstützen das) und den Dienst neu starten. Nur wenn die Quelle kein HTTPS anbietet und das Netz vertrauenswürdig ist, bewusst `ALLOW_INSECURE_LIST_URLS=true` setzen. Ein Angreifer im Netzwerkpfad kann sonst beliebige IPs auf die Blocklist setzen oder die Whitelist verändern.
---
## Web-Statusseite
Optionale, schreibgeschützte Statusseite mit Live-Ansicht. Einrichtung, Absicherung und Reverse-Proxy-Beispiele: [Web-Statusseite](webinterface.md).
+2
View File
@@ -53,6 +53,8 @@ Sie werden ins Binary eingebettet und bei `install` sowie `update` nach `/opt/ad
| `REPORT_MAIL_CMD` | `msmtp` | Mailprogramm für den Versand |
| `REPORT_BUSIEST_DAY_RANGE` | `30` | Zeitraum für "Aktivster Tag"; `0` nutzt den Berichtszeitraum |
`REPORT_EMAIL_FROM` erwartet genau eine Adresse, `REPORT_EMAIL_TO` eine oder mehrere kommagetrennte Adressen; beide auch in der Form `Name <adresse@example.com>`. Werte mit Zeilenumbrüchen oder anderen Steuerzeichen werden abgelehnt.
### Versandintervalle
| Intervall | Versandzeitpunkt |
+2
View File
@@ -53,6 +53,8 @@ sudo journalctl -u adguard-shield --no-pager -n 100
| Systempakete fehlen | `which iptables ip6tables ipset systemctl` prüfen |
| API nicht erreichbar | Erst AdGuard Home starten |
| Alte Shell-Artefakte | Go-Installer meldet Konflikte, alte Version deinstallieren |
| `permission denied` auf Log-, State- oder Cache-Dateien | Der Service hat kein `CAP_DAC_OVERRIDE` mehr (siehe [Rechte des systemd-Service](architektur.md#rechte-des-systemd-service)). Betroffene Dateien müssen root gehören bzw. für root beschreibbar sein, z. B. `sudo chown root: /var/log/adguard-shield.log` |
| `PID-Datei ... ist keine reguläre Datei` | `PID_FILE` zeigt auf einen Symlink, ein Verzeichnis o. Ä.; aus Sicherheitsgründen wird das abgelehnt. Eintrag entfernen oder `PID_FILE` auf eine normale Datei setzen |
| Unit manuell geändert | `sudo systemctl daemon-reload` ausführen |
| Konfiguration geändert, aber ohne Wirkung | `sudo systemctl restart adguard-shield` ausführen; `systemctl reload` lädt die Konfiguration nicht neu |
| Warnungen zur Konfiguration im Log | Tippfehler oder ungültige Werte; betroffene Parameter nutzen den Standardwert (siehe [Konfiguration](konfiguration.md#prüfung-beim-laden)) |
+4 -2
View File
@@ -42,8 +42,8 @@ Was automatisch passiert:
| Bereich | Änderung |
|---|---|
| Datenbank | Schema-Migration auf Version 2 (nur zusätzliche Indizes). v1.1.3 kann die Datenbank weiterhin lesen. |
| Konfiguration | Neue Parameter `ADGUARD_TLS_INSECURE`, `NOTIFY_TLS_INSECURE`, `LOG_MAX_SIZE_MB`, `BAN_HISTORY_RETENTION_DAYS` und die `WEB_*`-Parameter der optionalen [Web-Statusseite](webinterface.md) (standardmäßig aus) werden ergänzt (sofern noch nicht vorhanden), vorhandene Werte bleiben unverändert. Die bisherige Datei liegt danach als `adguard-shield.conf.old` vor (eine ältere `.old` wird dabei ersetzt). |
| systemd-Unit | Wird neu geschrieben, `ExecReload` entfällt. |
| Konfiguration | Neue Parameter `ADGUARD_TLS_INSECURE`, `NOTIFY_TLS_INSECURE`, `LOG_MAX_SIZE_MB`, `BAN_HISTORY_RETENTION_DAYS`, `ALLOW_INSECURE_LIST_URLS` und die `WEB_*`-Parameter der optionalen [Web-Statusseite](webinterface.md) (standardmäßig aus) werden ergänzt (sofern noch nicht vorhanden), vorhandene Werte bleiben unverändert. Die bisherige Datei liegt danach als `adguard-shield.conf.old` vor (eine ältere `.old` wird dabei ersetzt). |
| systemd-Unit | Wird neu geschrieben, `ExecReload` entfällt. Die Unit ist deutlich restriktiver: `NoNewPrivileges=true`, Capabilities nur noch `CAP_NET_ADMIN`, `CAP_NET_RAW` und `CAP_NET_BIND_SERVICE` sowie weitere Sandbox-Optionen (siehe [Rechte des systemd-Service](architektur.md#rechte-des-systemd-service)). Bestehende Installationen erhalten die neue Unit nur über `update` bzw. `install`; ein reines Austauschen des Binarys reicht nicht. |
| Firewall | Beim Start werden alle Sperren aus der Datenbank per `ipset restore` übernommen. Permanente Sperren, die durch einen Fehler in älteren Versionen nach `BAN_DURATION` aus dem ipset gefallen waren, sind danach wieder aktiv. |
Was du prüfen solltest:
@@ -52,7 +52,9 @@ Was du prüfen solltest:
|---|---|
| TLS-Zertifikate | Benachrichtigungen (ntfy, Gotify, Webhooks) und externe Listen prüfen jetzt das TLS-Zertifikat. Nutzt du einen eigenen Server mit selbstsigniertem Zertifikat, schlagen Benachrichtigungen fehl (`Benachrichtigung fehlgeschlagen` im Log). Abhilfe: gültiges Zertifikat oder `NOTIFY_TLS_INSECURE=true`. Für externe Listen gibt es keine Ausnahme – bei Fehlern bleibt die bisherige Whitelist erhalten bzw. der Cache wird genutzt. |
| AdGuard-API-Zertifikat | Auch das TLS-Zertifikat der AdGuard-Home-API wird jetzt geprüft (`ADGUARD_TLS_INSECURE=false`, wird beim Update ergänzt). Bis v1.1.x wurde es nie geprüft. Bei selbstsigniertem Zertifikat schlägt die Abfrage fehl (`sudo adguard-shield test` meldet einen Zertifikatsfehler). Abhilfe: gültiges Zertifikat, `ADGUARD_URL` auf `http://127.0.0.1:<port>` umstellen oder bewusst `ADGUARD_TLS_INSECURE=true` setzen. |
| Externe Listen über http:// | Externe White- und Blocklisten werden nur noch per `https://` geladen. `http://`-URLs werden beim Start als `WARNUNG` gemeldet und beim Abruf abgelehnt (die Whitelist bleibt unverändert, Auto-Unban der Blocklist wird ausgesetzt). Weiterleitungen von https auf http werden ebenfalls abgelehnt. Abhilfe: URL auf `https://` umstellen oder bewusst `ALLOW_INSECURE_LIST_URLS=true` setzen. Listen über 50 MB werden verworfen, stattdessen wird der Cache genutzt (siehe [Externe Listen: Transport und Größe](konfiguration.md#externe-listen-transport-und-größe)). |
| Konfigurations-Warnungen | Ungültige, unbekannte oder veraltete Parameter werden beim Start und bei jedem CLI-Befehl als `WARNUNG` gemeldet (z. B. `BAN_HISTORY_FILE` aus der alten Shell-Version). Ungültige Werte werden durch den Standardwert ersetzt. Veraltete Zeilen kannst du aus der Konfiguration löschen. |
| Eigene Pfade / Dateirechte | Der Service darf Dateirechte nicht mehr übergehen (kein `CAP_DAC_OVERRIDE`). Liegen `LOG_FILE`, `STATE_DIR`, Cache-Verzeichnisse oder `PID_FILE` an eigenen Orten, müssen sie root gehören bzw. für root beschreibbar sein. `PID_FILE` darf kein Symlink sein. Prüfen nach dem Neustart: `sudo journalctl -u adguard-shield -n 50`. |
| Logrotation | `LOG_MAX_SIZE_MB` (Standard 50) wird jetzt ausgewertet: Das Log wird nach `adguard-shield.log.1` rotiert. |
| `BLOCKED_PORTS` | Kommas sind jetzt erlaubt, ungültige Ports werden mit Warnung ignoriert. Port-Namen wie `domain` werden nicht mehr akzeptiert – bitte Portnummern verwenden. |
| `systemctl reload` | Wird nicht mehr unterstützt; nach Änderungen an der Konfiguration `systemctl restart adguard-shield` verwenden. |
+55
View File
@@ -2,8 +2,10 @@ package config
import (
"bufio"
"errors"
"fmt"
"net"
"net/url"
"os"
"path"
"path/filepath"
@@ -83,6 +85,8 @@ type Config struct {
ExternalBlocklistDuration int64
ExternalBlocklistAutoUnban bool
ExternalBlocklistNotify bool
// AllowInsecureListURLs erlaubt http:// für externe Block-/Whitelists.
AllowInsecureListURLs bool
ProgressiveBanEnabled bool
ProgressiveBanMultiplier int
@@ -193,6 +197,9 @@ func load(values map[string]string) (*Config, *reader) {
c.ExternalBlocklistDuration = int64(r.intMin("EXTERNAL_BLOCKLIST_BAN_DURATION", 0, 0))
c.ExternalBlocklistAutoUnban = r.bool("EXTERNAL_BLOCKLIST_AUTO_UNBAN", true)
c.ExternalBlocklistNotify = r.bool("EXTERNAL_BLOCKLIST_NOTIFY", false)
c.AllowInsecureListURLs = r.bool("ALLOW_INSECURE_LIST_URLS", false)
r.checkListURLs("EXTERNAL_WHITELIST_URLS", c.ExternalWhitelistURLs, c.AllowInsecureListURLs)
r.checkListURLs("EXTERNAL_BLOCKLIST_URLS", c.ExternalBlocklistURLs, c.AllowInsecureListURLs)
c.ProgressiveBanEnabled = r.bool("PROGRESSIVE_BAN_ENABLED", true)
c.ProgressiveBanMultiplier = r.intMin("PROGRESSIVE_BAN_MULTIPLIER", 2, 1)
c.ProgressiveBanMaxLevel = r.intMin("PROGRESSIVE_BAN_MAX_LEVEL", 5, 0)
@@ -485,6 +492,54 @@ func (r *reader) listen(k, def string) string {
return v
}
// ErrInsecureListURL meldet eine http://-Liste ohne ALLOW_INSECURE_LIST_URLS=true.
var ErrInsecureListURL = errors.New("unverschlüsseltes http:// ist nicht erlaubt, da die Liste per Man-in-the-Middle manipuliert werden kann; URL auf https:// umstellen oder bewusst ALLOW_INSECURE_LIST_URLS=true setzen")
// CheckListURL prüft eine URL für externe Block-/Whitelists: https:// ist
// immer erlaubt, http:// nur mit allowInsecure (dann ist insecure true), andere
// Schemata und URLs ohne Host nie. Fehler enthalten die URL bewusst nicht, da
// Pfad und Query Tokens enthalten können.
func CheckListURL(raw string, allowInsecure bool) (insecure bool, err error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return false, errors.New("ungültige URL")
}
switch strings.ToLower(u.Scheme) {
case "https":
case "http":
if !allowInsecure {
return false, ErrInsecureListURL
}
insecure = true
case "":
return false, errors.New("URL ohne Schema, erlaubt ist nur https://")
default:
return false, fmt.Errorf("Schema %s:// wird nicht unterstützt, erlaubt ist nur https://", strings.ToLower(u.Scheme))
}
if u.Hostname() == "" {
return false, errors.New("URL ohne Host")
}
return insecure, nil
}
// checkListURLs warnt beim Laden vor Listen-URLs, die der Download ablehnen
// wird oder die unverschlüsselt geladen werden.
func (r *reader) checkListURLs(k string, urls []string, allowInsecure bool) {
for i, raw := range urls {
src := fmt.Sprintf("Quelle #%d", i+1)
if u, err := url.Parse(raw); err == nil && u.Scheme != "" && u.Host != "" {
src += fmt.Sprintf(" (%s://%s)", u.Scheme, u.Host)
}
insecure, err := CheckListURL(raw, allowInsecure)
switch {
case err != nil:
r.warnf("%s: %s wird nicht geladen: %v", k, src, err)
case insecure:
r.warnf("%s: %s wird unverschlüsselt per http:// geladen (ALLOW_INSECURE_LIST_URLS=true); Inhalte können unterwegs manipuliert werden, https:// wird dringend empfohlen", k, src)
}
}
}
// basePath normalisiert einen URL-Pfad auf führenden und abschließenden Slash.
func (r *reader) basePath(k, def string) string {
v, ok := r.lookup(k)
+50 -1
View File
@@ -184,7 +184,7 @@ func TestKnownKeysContainsNewKeys(t *testing.T) {
for _, k := range KnownKeys() {
keys[k] = true
}
for _, k := range []string{"ADGUARD_TLS_INSECURE", "NOTIFY_TLS_INSECURE", "BAN_HISTORY_RETENTION_DAYS", "LOG_MAX_SIZE_MB", "CHECK_INTERVAL", "GEOIP_CACHE_TTL"} {
for _, k := range []string{"ADGUARD_TLS_INSECURE", "NOTIFY_TLS_INSECURE", "ALLOW_INSECURE_LIST_URLS", "BAN_HISTORY_RETENTION_DAYS", "LOG_MAX_SIZE_MB", "CHECK_INTERVAL", "GEOIP_CACHE_TTL"} {
if !keys[k] {
t.Fatalf("KnownKeys misses %s", k)
}
@@ -323,3 +323,52 @@ func TestLoadWebTLSPairWarning(t *testing.T) {
t.Errorf("Doppelpunkt im Benutzernamen nicht gewarnt: %v", c.Warnings)
}
}
func TestCheckListURL(t *testing.T) {
cases := []struct {
url string
allow bool
wantInsecure bool
wantErr bool
}{
{"https://example.com/list.txt", false, false, false},
{"HTTPS://example.com/list.txt", false, false, false},
{"http://example.com/list.txt", false, false, true},
{"http://example.com/list.txt", true, true, false},
{"file:///etc/passwd", true, false, true},
{"ftp://example.com/list.txt", true, false, true},
{"https:///list.txt", true, false, true},
{"http://", true, false, true},
{"example.com/list.txt", true, false, true},
{"", true, false, true},
}
for _, tc := range cases {
insecure, err := CheckListURL(tc.url, tc.allow)
if (err != nil) != tc.wantErr || insecure != tc.wantInsecure {
t.Errorf("CheckListURL(%q, %v) = %v, %v", tc.url, tc.allow, insecure, err)
}
}
if _, err := CheckListURL("http://example.com/x?token=geheim", false); err == nil || !strings.Contains(err.Error(), "ALLOW_INSECURE_LIST_URLS=true") || strings.Contains(err.Error(), "geheim") {
t.Fatalf("Fehler muss die Abhilfe nennen und darf die URL nicht enthalten: %v", err)
}
}
func TestLoadWarnsAboutListURLs(t *testing.T) {
c := loadString(t, `EXTERNAL_BLOCKLIST_URLS="https://ok.example/a.txt,http://plain.example/b.txt?token=geheim"
EXTERNAL_WHITELIST_URLS="file:///etc/hosts"
`)
if c.AllowInsecureListURLs {
t.Fatal("ALLOW_INSECURE_LIST_URLS must default to false")
}
if !hasWarning(c, "EXTERNAL_BLOCKLIST_URLS: Quelle #2 (http://plain.example) wird nicht geladen") || !hasWarning(c, "EXTERNAL_WHITELIST_URLS: Quelle #1 wird nicht geladen") {
t.Fatalf("missing warnings: %v", c.Warnings)
}
if hasWarning(c, "Quelle #1 (https://ok.example)") || hasWarning(c, "geheim") {
t.Fatalf("unexpected warning: %v", c.Warnings)
}
c = loadString(t, "ALLOW_INSECURE_LIST_URLS=true\nEXTERNAL_BLOCKLIST_URLS=\"http://plain.example/b.txt\"\n")
if !c.AllowInsecureListURLs || !hasWarning(c, "unverschlüsselt per http://") {
t.Fatalf("insecure URL must be allowed with a warning: %v", c.Warnings)
}
}
+3
View File
@@ -70,6 +70,9 @@ type Daemon struct {
queryLogLimitWarned time.Time
// insecureListWarned merkt sich bereits gemeldete http://-Listen.
insecureListWarned sync.Map
banMu sync.Mutex
geoMu sync.Mutex
+64 -2
View File
@@ -14,6 +14,7 @@ import (
"strings"
"time"
"adguard-shield/internal/config"
"adguard-shield/internal/db"
"adguard-shield/internal/firewall"
)
@@ -249,6 +250,48 @@ func previewList(items []string, n int) string {
return strings.Join(items[:n], ", ") + ", ..."
}
// maxListBytes begrenzt die Größe einer heruntergeladenen Liste.
const maxListBytes = 50 << 20
// maxListRedirects entspricht der Standardgrenze von net/http.
const maxListRedirects = 10
// listClient liefert eine Kopie von d.Client, deren Weiterleitungen dieselben
// Regeln wie die konfigurierte URL erfüllen müssen (kein https→http ohne
// ALLOW_INSECURE_LIST_URLS). d.Client selbst bleibt unverändert, da ihn auch
// andere Module (z.B. AbuseIPDB) nutzen.
func (d *Daemon) listClient() *http.Client {
base := d.Client
if base == nil {
base = http.DefaultClient
}
c := *base
allow := d.Config.AllowInsecureListURLs
prev := base.CheckRedirect
c.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if _, err := config.CheckListURL(req.URL.String(), allow); err != nil {
return fmt.Errorf("Weiterleitung abgelehnt: %w", err)
}
if prev != nil {
return prev(req, via)
}
if len(via) >= maxListRedirects {
return fmt.Errorf("mehr als %d Weiterleitungen", maxListRedirects)
}
return nil
}
return &c
}
// warnInsecureList warnt einmal pro Prozess und Quelle vor einem
// unverschlüsselten Listen-Download.
func (d *Daemon) warnInsecureList(prefix, src string) {
if _, loaded := d.insecureListWarned.LoadOrStore(prefix+"|"+src, true); loaded {
return
}
d.warn("%s %s wird unverschlüsselt per http:// geladen (ALLOW_INSECURE_LIST_URLS=true); Inhalte können unterwegs manipuliert werden, bitte auf https:// umstellen", prefix, src)
}
// fetchCachedLines lädt eine Liste; fromCache meldet, dass der Download
// fehlschlug und stattdessen der Cache genutzt wurde.
func (d *Daemon) fetchCachedLines(ctx context.Context, sourceURL, cacheDir, prefix string, index int) (lines []string, fromCache bool, err error) {
@@ -257,6 +300,16 @@ func (d *Daemon) fetchCachedLines(ctx context.Context, sourceURL, cacheDir, pref
tmpFile := filepath.Join(cacheDir, fmt.Sprintf("%s_%d.tmp", prefix, index))
src := listSource(index, sourceURL)
// Abgelehnte URLs nutzen bewusst keinen Cache: Dessen Inhalt stammt ggf.
// aus einem unverschlüsselten Download.
insecure, err := config.CheckListURL(sourceURL, d.Config.AllowInsecureListURLs)
if err != nil {
return nil, false, err
}
if insecure {
d.warnInsecureList(prefix, src)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return nil, false, listErr(err)
@@ -266,7 +319,7 @@ func (d *Daemon) fetchCachedLines(ctx context.Context, sourceURL, cacheDir, pref
req.Header.Set("If-None-Match", value)
}
}
resp, err := d.Client.Do(req)
resp, err := d.listClient().Do(req)
if err != nil {
err = listErr(err)
if b, readErr := os.ReadFile(cacheFile); readErr == nil {
@@ -291,10 +344,19 @@ func (d *Daemon) fetchCachedLines(ctx context.Context, sourceURL, cacheDir, pref
}
return nil, false, fmt.Errorf("HTTP %d", resp.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 50<<20))
b, err := io.ReadAll(io.LimitReader(resp.Body, maxListBytes+1))
if err != nil {
return nil, false, listErr(err)
}
if len(b) > maxListBytes {
// Nie abgeschnitten übernehmen: Eine gekürzte Liste würde Einträge
// verlieren (Auto-Unban bzw. Whitelist-Abgleich).
if cached, readErr := os.ReadFile(cacheFile); readErr == nil {
d.warn("%s Liste größer als %d MB, nutze Cache: %s", prefix, maxListBytes>>20, src)
return strings.Split(string(cached), "\n"), true, nil
}
return nil, false, fmt.Errorf("Liste größer als %d MB", maxListBytes>>20)
}
if err := os.WriteFile(tmpFile, b, 0644); err != nil {
return nil, false, err
}
+194
View File
@@ -0,0 +1,194 @@
package daemon
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"adguard-shield/internal/config"
"adguard-shield/internal/syslog"
)
// plainListServer liefert eine Liste unverschlüsselt per http:// und zählt
// die Abrufe.
func plainListServer(t *testing.T, body string, hits *atomic.Int32) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
io.WriteString(w, body)
}))
t.Cleanup(srv.Close)
return srv
}
func TestListHTTPRejectedByDefault(t *testing.T) {
var hits atomic.Int32
srv := plainListServer(t, "203.0.113.9\n", &hits)
cacheDir := t.TempDir()
// Ein vorhandener Cache darf für abgelehnte URLs nicht genutzt werden.
if err := os.WriteFile(filepath.Join(cacheDir, "blocklist_0.txt"), []byte("203.0.113.10\n"), 0644); err != nil {
t.Fatal(err)
}
cfg := &config.Config{ExternalBlocklistURLs: []string{srv.URL + "/list.txt"}, ExternalBlocklistCacheDir: cacheDir}
d, _ := newTestDaemon(t, cfg)
var logBuf bytes.Buffer
d.Logger = syslog.New(&logBuf, "DEBUG")
if err := d.SyncBlocklist(context.Background()); err == nil {
t.Fatal("http:// ohne ALLOW_INSECURE_LIST_URLS muss fehlschlagen")
}
if hits.Load() != 0 {
t.Fatalf("http-Quelle wurde abgerufen: %d", hits.Load())
}
for _, ip := range []string{"203.0.113.9", "203.0.113.10"} {
if ok, _ := d.Store.BanExists(ip); ok {
t.Fatalf("%s darf nicht gesperrt werden", ip)
}
}
if !strings.Contains(logBuf.String(), "ALLOW_INSECURE_LIST_URLS=true") || !strings.Contains(logBuf.String(), "https://") {
t.Fatalf("Warnung muss die Abhilfe nennen:\n%s", logBuf.String())
}
}
func TestListHTTPAllowedWithFlag(t *testing.T) {
var hits atomic.Int32
srv := plainListServer(t, "203.0.113.9\n", &hits)
cfg := &config.Config{ExternalBlocklistURLs: []string{srv.URL + "/list.txt"}, ExternalBlocklistCacheDir: t.TempDir(), AllowInsecureListURLs: true}
d, _ := newTestDaemon(t, cfg)
var logBuf bytes.Buffer
d.Logger = syslog.New(&logBuf, "DEBUG")
for i := 0; i < 2; i++ {
if err := d.SyncBlocklist(context.Background()); err != nil {
t.Fatal(err)
}
}
if ok, _ := d.Store.BanExists("203.0.113.9"); !ok {
t.Fatal("Eintrag der http-Liste muss mit ALLOW_INSECURE_LIST_URLS=true gesperrt werden")
}
if n := strings.Count(logBuf.String(), "unverschlüsselt per http://"); n != 1 {
t.Fatalf("erwartet genau eine Warnung zur unverschlüsselten Quelle, gefunden %d:\n%s", n, logBuf.String())
}
}
func TestListRejectsUnsupportedSchemes(t *testing.T) {
cfg := &config.Config{ExternalBlocklistCacheDir: t.TempDir(), AllowInsecureListURLs: true}
d, _ := newTestDaemon(t, cfg)
for _, u := range []string{"file:///etc/passwd", "ftp://example.com/list.txt", "https:///list.txt", "http://", "example.com/list.txt"} {
if _, _, err := d.fetchCachedLines(context.Background(), u, cfg.ExternalBlocklistCacheDir, "blocklist", 0); err == nil {
t.Errorf("%q muss abgelehnt werden", u)
}
}
}
func TestListRedirectDowngradeRejected(t *testing.T) {
var hits atomic.Int32
plain := plainListServer(t, "203.0.113.9\n", &hits)
redir := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, plain.URL+"/list.txt", http.StatusFound)
}))
t.Cleanup(redir.Close)
cfg := &config.Config{ExternalBlocklistURLs: []string{redir.URL + "/list.txt"}, ExternalBlocklistCacheDir: t.TempDir()}
d, _ := newTestDaemon(t, cfg)
useListServer(d, redir)
if err := d.SyncBlocklist(context.Background()); err == nil {
t.Fatal("Weiterleitung https→http muss fehlschlagen")
}
if hits.Load() != 0 {
t.Fatalf("http-Ziel der Weiterleitung wurde abgerufen: %d", hits.Load())
}
if ok, _ := d.Store.BanExists("203.0.113.9"); ok {
t.Fatal("Eintrag hinter einer https→http-Weiterleitung darf nicht gesperrt werden")
}
if d.Client.CheckRedirect != nil {
t.Fatal("der gemeinsame d.Client darf nicht verändert werden")
}
cfg.AllowInsecureListURLs = true
if err := d.SyncBlocklist(context.Background()); err != nil {
t.Fatalf("mit ALLOW_INSECURE_LIST_URLS=true muss die Weiterleitung erlaubt sein: %v", err)
}
if ok, _ := d.Store.BanExists("203.0.113.9"); !ok {
t.Fatal("Eintrag nach erlaubter Weiterleitung fehlt")
}
}
// oversizeServer liefert maxListBytes+1 Bytes gültiger Listeneinträge.
func oversizeServer(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("ETag", `"oversize"`)
line := []byte("203.0.113.77\n")
chunk := bytes.Repeat(line, 64<<10/len(line))
remaining := maxListBytes + 1
for remaining > 0 {
n := min(remaining, len(chunk))
if _, err := w.Write(chunk[:n]); err != nil {
return
}
remaining -= n
}
}))
t.Cleanup(srv.Close)
return srv
}
func TestListOversizeFallsBackToCache(t *testing.T) {
srv := oversizeServer(t)
cacheDir := t.TempDir()
cacheFile := filepath.Join(cacheDir, "blocklist_0.txt")
if err := os.WriteFile(cacheFile, []byte("198.51.100.1\n"), 0644); err != nil {
t.Fatal(err)
}
cfg := &config.Config{ExternalBlocklistURLs: []string{srv.URL + "/list.txt"}, ExternalBlocklistCacheDir: cacheDir}
d, _ := newTestDaemon(t, cfg)
useListServer(d, srv)
var logBuf bytes.Buffer
d.Logger = syslog.New(&logBuf, "DEBUG")
err := d.SyncBlocklist(context.Background())
if err == nil || !strings.Contains(err.Error(), "1 von 1 Quellen fehlgeschlagen") {
t.Fatalf("zu große Liste muss als fehlgeschlagen gemeldet werden: %v", err)
}
if b, _ := os.ReadFile(cacheFile); string(b) != "198.51.100.1\n" {
t.Fatalf("Cache wurde überschrieben (%d Bytes)", len(b))
}
if _, err := os.Stat(filepath.Join(cacheDir, "blocklist_0.etag")); !os.IsNotExist(err) {
t.Fatal("ETag einer verworfenen Liste darf nicht gespeichert werden")
}
if ok, _ := d.Store.BanExists("198.51.100.1"); !ok {
t.Fatal("Einträge aus dem Cache müssen weiter genutzt werden")
}
if ok, _ := d.Store.BanExists("203.0.113.77"); ok {
t.Fatal("Einträge der zu großen Liste dürfen nicht übernommen werden")
}
if !strings.Contains(logBuf.String(), "größer als 50 MB, nutze Cache") {
t.Fatalf("Warnung fehlt:\n%s", logBuf.String())
}
}
func TestListOversizeWithoutCacheFails(t *testing.T) {
srv := oversizeServer(t)
cacheDir := t.TempDir()
cfg := &config.Config{ExternalBlocklistCacheDir: cacheDir}
d, _ := newTestDaemon(t, cfg)
useListServer(d, srv)
lines, fromCache, err := d.fetchCachedLines(context.Background(), srv.URL+"/list.txt", cacheDir, "blocklist", 0)
if err == nil || !strings.Contains(err.Error(), "größer als 50 MB") || lines != nil || fromCache {
t.Fatalf("erwartet Fehler ohne Cache: lines=%d fromCache=%v err=%v", len(lines), fromCache, err)
}
for _, name := range []string{"blocklist_0.txt", "blocklist_0.tmp", "blocklist_0.etag"} {
if _, err := os.Stat(filepath.Join(cacheDir, name)); !os.IsNotExist(err) {
t.Fatalf("%s darf nicht geschrieben werden", name)
}
}
}
+14 -2
View File
@@ -326,8 +326,11 @@ func TestBanSkipsWhenRowInsertedByOtherProcess(t *testing.T) {
}
}
// listServer liefert einen HTTPS-Testserver; externe Listen werden ohne
// ALLOW_INSECURE_LIST_URLS nur per https:// geladen. Der Daemon braucht dafür
// srv.Client() (siehe useListServer).
func listServer(t *testing.T, body *atomic.Value) *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
v, _ := body.Load().(string)
if v == "FAIL" {
http.Error(w, "kaputt", http.StatusInternalServerError)
@@ -339,8 +342,13 @@ func listServer(t *testing.T, body *atomic.Value) *httptest.Server {
return srv
}
// useListServer lässt d dem Zertifikat des Testservers vertrauen.
func useListServer(d *Daemon, srv *httptest.Server) {
d.Client = srv.Client()
}
func deadURL(t *testing.T) string {
srv := httptest.NewServer(http.NotFoundHandler())
srv := httptest.NewTLSServer(http.NotFoundHandler())
u := srv.URL + "/list.txt"
srv.Close()
return u
@@ -352,6 +360,7 @@ func TestSyncWhitelistFailureKeepsOldEntries(t *testing.T) {
srv := listServer(t, &body)
cfg := &config.Config{ExternalWhitelistURLs: []string{srv.URL, deadURL(t)}, ExternalWhitelistCacheDir: t.TempDir()}
d, _ := newTestDaemon(t, cfg)
useListServer(d, srv)
if err := d.Store.ReplaceWhitelist([]string{"192.0.2.1"}, "external"); err != nil {
t.Fatal(err)
}
@@ -393,6 +402,7 @@ func TestSyncBlocklistFailureSkipsAutoUnban(t *testing.T) {
ExternalBlocklistAutoUnban: true,
}
d, _ := newTestDaemon(t, cfg)
useListServer(d, srv)
if err := d.Store.InsertBan(db.Ban{IP: "198.51.100.7", Source: "external-blocklist", Reason: "external-blocklist", Permanent: true}); err != nil {
t.Fatal(err)
}
@@ -428,6 +438,7 @@ func TestSyncBlocklistAddsOnlyNewEntriesInOneBatch(t *testing.T) {
ExternalBlocklistDuration: 7200,
}
d, ex := newTestDaemon(t, cfg)
useListServer(d, srv)
if err := d.Store.InsertBan(db.Ban{IP: "203.0.113.2", Source: "monitor", Reason: "rate-limit", BanUntil: time.Now().Unix() + 100, Duration: 100}); err != nil {
t.Fatal(err)
}
@@ -492,6 +503,7 @@ func TestSyncBlocklistNotificationSummary(t *testing.T) {
NotifyEnabled: true, NotifyType: "generic", NotifyWebhook: sink.srv.URL,
ExternalBlocklistNotify: true, ExternalBlocklistURLs: []string{srv.URL}, ExternalBlocklistCacheDir: t.TempDir(),
})
useListServer(d, srv)
if err := d.SyncBlocklist(context.Background()); err != nil {
t.Fatal(err)
}
+2
View File
@@ -60,6 +60,7 @@ func TestSyncWhitelistIgnoresBroadEntries(t *testing.T) {
srv := listServer(t, &body)
cfg := &config.Config{ExternalWhitelistURLs: []string{srv.URL}, ExternalWhitelistCacheDir: t.TempDir()}
d, _ := newTestDaemon(t, cfg)
useListServer(d, srv)
for _, ip := range []string{"203.0.113.1", "198.51.100.7"} {
if err := d.Store.InsertBan(db.Ban{IP: ip, Source: "monitor", Reason: "rate-limit", Permanent: true}); err != nil {
t.Fatal(err)
@@ -124,6 +125,7 @@ func TestSyncWhitelistSafetyBrake(t *testing.T) {
srv := listServer(t, &body)
cfg := &config.Config{ExternalWhitelistURLs: []string{srv.URL}, ExternalWhitelistCacheDir: t.TempDir()}
d, _ := newTestDaemon(t, cfg)
useListServer(d, srv)
insertBans(t, d.Store, "10.0.0", 150)
insertBans(t, d.Store, "203.0.113", 10)
if err := d.SyncWhitelist(context.Background()); err != nil {
+6 -4
View File
@@ -22,9 +22,9 @@ func TestListURLsNotExposed(t *testing.T) {
var body atomic.Value
body.Store("203.0.113.5\n")
srv := listServer(t, &body)
host := strings.TrimPrefix(srv.URL, "http://")
blockURL := "http://listuser:pw-geheim-77@" + host + "/block.txt?token=list-token-999"
dead := httptest.NewServer(http.NotFoundHandler())
host := strings.TrimPrefix(srv.URL, "https://")
blockURL := "https://listuser:pw-geheim-77@" + host + "/block.txt?token=list-token-999"
dead := httptest.NewTLSServer(http.NotFoundHandler())
deadURL := dead.URL + "/x.txt?token=dead-token-555"
dead.Close()
wlURL := srv.URL + "/allow.txt?key=wl-token-4242"
@@ -37,6 +37,7 @@ func TestListURLsNotExposed(t *testing.T) {
cfg.ExternalWhitelistURLs = []string{wlURL}
cfg.NTFYServerURL = "https://ntfy-user:ntfy-pw-31337@ntfy.example"
d, _ := newTestDaemon(t, cfg)
useListServer(d, srv)
logFile, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
t.Fatal(err)
@@ -61,7 +62,7 @@ func TestListURLsNotExposed(t *testing.T) {
t.Fatal(err)
}
logged := string(raw)
for _, want := range []string{"nutze Cache: Quelle #1 (http://" + host + ")", "Quelle #2 (http://"} {
for _, want := range []string{"nutze Cache: Quelle #1 (https://" + host + ")", "Quelle #2 (https://"} {
if !strings.Contains(logged, want) {
t.Fatalf("Log enthält %q nicht:\n%s", want, logged)
}
@@ -169,6 +170,7 @@ func TestSyncBlocklistReportsStaleCache(t *testing.T) {
cfg.ExternalBlocklistURLs = []string{srv.URL}
cfg.ExternalBlocklistCacheDir = t.TempDir()
d, _ := newTestDaemon(t, cfg)
useListServer(d, srv)
if err := d.SyncBlocklist(context.Background()); err != nil {
t.Fatal(err)
}
+26 -5
View File
@@ -589,16 +589,33 @@ Restart=on-failure
RestartSec=30
# Sicherheits-Hardening
# Der Daemon läuft als root, aber nur mit den Capabilities, die er wirklich
# nutzt. Log, State-DB, Listen-Caches, GeoIP-Downloads und PID-Datei gehören
# root, daher sind CAP_DAC_OVERRIDE, CAP_DAC_READ_SEARCH, CAP_FOWNER und
# CAP_CHOWN nicht nötig. Der Daemon wechselt keine Benutzer (kein CAP_SETUID/
# CAP_SETGID) und signalisiert keine fremden Prozesse (kein CAP_KILL).
# E-Mail-Reports laufen per Cron (report-send) außerhalb dieses Service;
# NoNewPrivileges betrifft sendmail/postdrop daher nicht.
ProtectSystem=full
ReadWritePaths=/var/log /var/lib/adguard-shield /var/run %[1]s/geoip
ProtectHome=true
NoNewPrivileges=false
PrivateTmp=true
NoNewPrivileges=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictRealtime=true
ProtectClock=true
ProtectControlGroups=true
ProtectKernelLogs=true
SystemCallArchitectures=native
# Bewusst nicht gesetzt: ProtectKernelTunables (iptables/ipset können
# /proc/sys/net beschreiben) und ProtectKernelModules (Netfilter-Module).
# iptables benötigt CAP_NET_ADMIN + CAP_NET_RAW
# Weitere Capabilities für Dateizugriff, Signale und Prozessverwaltung
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_KILL CAP_SETUID CAP_SETGID CAP_CHOWN
# CAP_NET_ADMIN: iptables, ip6tables, ipset
# CAP_NET_RAW: iptables-legacy nutzt einen Raw-Socket
# CAP_NET_BIND_SERVICE: nur für WEB_LISTEN mit einem Port unter 1024
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE
# Logging
StandardOutput=journal
@@ -733,6 +750,10 @@ EXTERNAL_BLOCKLIST_AUTO_UNBAN=true
EXTERNAL_BLOCKLIST_NOTIFY=false # Bei großen Listen auf false lassen
EXTERNAL_BLOCKLIST_CACHE_DIR="/var/lib/adguard-shield/external-blocklist"
# --- Externe Listen: Transport ---
# Externe White-/Blocklisten werden standardmäßig nur per https:// geladen.
ALLOW_INSECURE_LIST_URLS=false # true = auch http:// erlauben (unsicher, Listen können manipuliert werden)
# --- Progressive Sperren (Recidive) ---
# Wiederholungstäter werden stufenweise länger gesperrt
PROGRESSIVE_BAN_ENABLED=true
+23
View File
@@ -82,6 +82,29 @@ func TestServiceUnitMatchesRepoService(t *testing.T) {
}
}
func TestServiceUnitHardening(t *testing.T) {
d := directives(serviceUnit(DefaultInstallDir))
has := map[string]bool{}
for _, line := range d {
has[line] = true
}
for _, want := range []string{
"NoNewPrivileges=true",
"CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE",
} {
if !has[want] {
t.Errorf("serviceUnit enthaelt %q nicht", want)
}
}
for _, line := range d {
for _, bad := range []string{"CAP_DAC_OVERRIDE", "CAP_DAC_READ_SEARCH", "CAP_SETUID", "CAP_SETGID", "CAP_CHOWN", "CAP_FOWNER", "CAP_KILL", "CAP_SYS_ADMIN"} {
if strings.Contains(line, bad) {
t.Errorf("serviceUnit gewaehrt %s: %q", bad, line)
}
}
}
}
func TestServiceUnitUsesInstallDir(t *testing.T) {
unit := serviceUnit("/srv/shield")
for _, want := range []string{
+75 -6
View File
@@ -11,12 +11,14 @@ import (
"mime"
"mime/quotedprintable"
"net/http"
"net/mail"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"unicode"
"adguard-shield/internal/appinfo"
"adguard-shield/internal/config"
@@ -131,6 +133,9 @@ func InstallCron(binary, configPath string, c *config.Config) error {
if !c.ReportEnabled {
return fmt.Errorf("Report ist deaktiviert (REPORT_ENABLED=false)")
}
if err := ValidateEmailConfig(c); err != nil {
return err
}
minute, hour, err := parseReportTime(c.ReportTime)
if err != nil {
return err
@@ -169,6 +174,10 @@ func sendMail(ctx context.Context, c *config.Config, subject, body, format, xMai
if strings.TrimSpace(c.ReportEmailTo) == "" {
return fmt.Errorf("REPORT_EMAIL_TO ist leer")
}
msg, err := buildMessage(c.ReportEmailFrom, c.ReportEmailTo, subject, body, format, xMailer)
if err != nil {
return err
}
parts := strings.Fields(c.ReportMailCmd)
if len(parts) == 0 {
return fmt.Errorf("REPORT_MAIL_CMD ist leer")
@@ -176,10 +185,6 @@ func sendMail(ctx context.Context, c *config.Config, subject, body, format, xMai
if _, err := exec.LookPath(parts[0]); err != nil {
return fmt.Errorf("Mail-Befehl nicht gefunden: %s", parts[0])
}
msg, err := buildMessage(c.ReportEmailFrom, c.ReportEmailTo, subject, body, format, xMailer)
if err != nil {
return err
}
args := append(parts[1:], "-t")
cmd := exec.CommandContext(ctx, parts[0], args...)
cmd.Stdin = strings.NewReader(msg)
@@ -188,10 +193,74 @@ func sendMail(ctx context.Context, c *config.Config, subject, body, format, xMai
return cmd.Run()
}
// ValidateEmailConfig prüft REPORT_EMAIL_FROM und REPORT_EMAIL_TO, damit eine
// Fehlkonfiguration früh auffällt (z. B. beim Start oder bei report-install).
// Erlaubt sind eine Absenderadresse und eine oder mehrere kommagetrennte
// Empfängeradressen, jeweils auch in der Form "Name <adresse@example.com>".
func ValidateEmailConfig(c *config.Config) error {
_, _, err := parseAddresses(c.ReportEmailFrom, c.ReportEmailTo)
return err
}
// parseAddresses validiert Absender und Empfänger und liefert die Header-Werte
// aus den geparsten Adressen. Da sendmail mit -t läuft, würden per CR/LF
// eingeschleuste Header (z. B. Bcc:) sonst zu echten Empfängern.
func parseAddresses(from, to string) (string, string, error) {
if err := checkHeaderValue("REPORT_EMAIL_FROM", from); err != nil {
return "", "", err
}
if err := checkHeaderValue("REPORT_EMAIL_TO", to); err != nil {
return "", "", err
}
if strings.TrimSpace(from) == "" {
return "", "", fmt.Errorf("REPORT_EMAIL_FROM ist leer")
}
if strings.TrimSpace(to) == "" {
return "", "", fmt.Errorf("REPORT_EMAIL_TO ist leer")
}
fromAddr, err := mail.ParseAddress(from)
if err != nil {
return "", "", fmt.Errorf("REPORT_EMAIL_FROM ist keine gueltige E-Mail-Adresse (%q): %v", from, err)
}
toAddrs, err := mail.ParseAddressList(to)
if err != nil {
return "", "", fmt.Errorf("REPORT_EMAIL_TO enthaelt keine gueltige E-Mail-Adressliste (%q): %v", to, err)
}
if len(toAddrs) == 0 {
return "", "", fmt.Errorf("REPORT_EMAIL_TO ist leer")
}
list := make([]string, 0, len(toAddrs))
for _, a := range toAddrs {
list = append(list, a.String())
}
return fromAddr.String(), strings.Join(list, ", "), nil
}
// checkHeaderValue lehnt Steuerzeichen (insbesondere CR/LF) in Header-Werten ab.
func checkHeaderValue(name, value string) error {
for _, r := range value {
if unicode.IsControl(r) {
return fmt.Errorf("%s enthaelt unzulaessige Steuerzeichen (z. B. Zeilenumbruch): %q", name, value)
}
}
return nil
}
// buildMessage baut die Mail für "sendmail -t". Der Body wird quoted-printable
// kodiert, da generierte Tabellen sonst Zeilen über 998 Zeichen erzeugen (RFC 5322).
// Zeilenenden bleiben LF wie bei den Headern; der MTA wandelt sie in CRLF um.
// Alle Header-Werte werden vorher validiert, damit keine Header eingeschleust werden können.
func buildMessage(from, to, subject, body, format, xMailer string) (string, error) {
fromHeader, toHeader, err := parseAddresses(from, to)
if err != nil {
return "", err
}
if err := checkHeaderValue("Betreff", subject); err != nil {
return "", err
}
if err := checkHeaderValue("X-Mailer", xMailer); err != nil {
return "", err
}
contentType := "text/plain; charset=UTF-8"
if strings.EqualFold(format, "html") {
contentType = "text/html; charset=UTF-8"
@@ -205,8 +274,8 @@ func buildMessage(from, to, subject, body, format, xMailer string) (string, erro
return "", err
}
return strings.Join([]string{
"From: " + from,
"To: " + to,
"From: " + fromHeader,
"To: " + toHeader,
"Subject: " + mime.QEncoding.Encode("utf-8", subject),
"MIME-Version: 1.0",
"Content-Type: " + contentType,
+85
View File
@@ -346,3 +346,88 @@ func TestGenerateDoesNotReplacePlaceholdersInValues(t *testing.T) {
t.Fatalf("placeholder inside value was replaced\n%s", txt)
}
}
func TestBuildMessageRejectsHeaderInjection(t *testing.T) {
const (
from = "shield@example.test"
to = "admin@example.test"
subject = "AdGuard Shield Test"
mailer = "test"
)
tests := []struct {
name string
from, to, subject, xMailer string
wantErr string
}{
{"From mit LF und Bcc", "shield@example.test\nBcc: evil@example.org", to, subject, mailer, "REPORT_EMAIL_FROM"},
{"From mit CRLF", "shield@example.test\r\nBcc: evil@example.org", to, subject, mailer, "REPORT_EMAIL_FROM"},
{"From mit CR", "shield@example.test\rTo: evil@example.org", to, subject, mailer, "REPORT_EMAIL_FROM"},
{"From mit NUL", "shield@example.test\x00", to, subject, mailer, "REPORT_EMAIL_FROM"},
{"From ungueltig", "kein-at-zeichen", to, subject, mailer, "REPORT_EMAIL_FROM"},
{"From mehrere Adressen", "a@example.test, b@example.test", to, subject, mailer, "REPORT_EMAIL_FROM"},
{"From leer", " ", to, subject, mailer, "REPORT_EMAIL_FROM ist leer"},
{"To mit LF und Bcc", from, "admin@example.test\nBcc: evil@example.org", subject, mailer, "REPORT_EMAIL_TO"},
{"To mit CRLF und To", from, "admin@example.test\r\nTo: evil@example.org", subject, mailer, "REPORT_EMAIL_TO"},
{"To mit Tab", from, "admin@example.test\tx", subject, mailer, "REPORT_EMAIL_TO"},
{"To ungueltig", from, "admin@", subject, mailer, "REPORT_EMAIL_TO"},
{"To leer", from, "", subject, mailer, "REPORT_EMAIL_TO ist leer"},
{"Betreff mit LF", from, to, "Report\nBcc: evil@example.org", mailer, "Betreff"},
{"Betreff mit CRLF", from, to, "Report\r\nBcc: evil@example.org", mailer, "Betreff"},
{"X-Mailer mit LF", from, to, subject, "test\nBcc: evil@example.org", "X-Mailer"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg, err := buildMessage(tt.from, tt.to, tt.subject, "body", "txt", tt.xMailer)
if err == nil {
t.Fatalf("expected error, got message\n%s", msg)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error %q does not mention %q", err, tt.wantErr)
}
})
}
}
func TestBuildMessageValidAddresses(t *testing.T) {
tests := []struct {
name, from, to string
wantFrom, wantTo string
}{
{"einzelner Empfaenger", "shield@example.test", "admin@example.test", "<shield@example.test>", "<admin@example.test>"},
{"mehrere Empfaenger", "shield@example.test", "admin@example.test, ops@example.test,noc@example.test", "<shield@example.test>", "<admin@example.test>, <ops@example.test>, <noc@example.test>"},
{"Anzeigename", "AdGuard Shield <shield@example.test>", `"Admin, Team" <admin@example.test>, Ops <ops@example.test>`, `"AdGuard Shield" <shield@example.test>`, `"Admin, Team" <admin@example.test>, "Ops" <ops@example.test>`},
{"Anzeigename mit Umlaut", "Schutzschild Größe <shield@example.test>", "admin@example.test", "=?utf-8?q?Schutzschild_Gr=C3=B6=C3=9Fe?= <shield@example.test>", "<admin@example.test>"},
{"Leerzeichen am Rand", " shield@example.test ", " admin@example.test ", "<shield@example.test>", "<admin@example.test>"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg, err := buildMessage(tt.from, tt.to, "AdGuard Shield Wochenbericht – Test", "body", "txt", "test")
if err != nil {
t.Fatal(err)
}
header, _, _ := strings.Cut(msg, "\n\n")
lines := strings.Split(header, "\n")
if lines[0] != "From: "+tt.wantFrom {
t.Fatalf("From header = %q, want %q", lines[0], "From: "+tt.wantFrom)
}
if lines[1] != "To: "+tt.wantTo {
t.Fatalf("To header = %q, want %q", lines[1], "To: "+tt.wantTo)
}
if !strings.HasPrefix(lines[2], "Subject: =?utf-8?q?") {
t.Fatalf("subject not Q-encoded: %q", lines[2])
}
if len(lines) != 7 {
t.Fatalf("expected 7 header lines, got %d\n%s", len(lines), header)
}
})
}
}
func TestValidateEmailConfig(t *testing.T) {
if err := ValidateEmailConfig(&config.Config{ReportEmailFrom: "shield@example.test", ReportEmailTo: "a@example.test, b@example.test"}); err != nil {
t.Fatal(err)
}
if err := ValidateEmailConfig(&config.Config{ReportEmailFrom: "shield@example.test", ReportEmailTo: "a@example.test\nBcc: evil@example.org"}); err == nil {
t.Fatal("expected error for injected Bcc header")
}
}