feat(domain): admin blocked-domains list + strict validation

- Admin domain panel gains a blocked-domains textarea (one domain per
  line); entries and all their subdomains are refused by mgr.AddDomain —
  the single add path — using label-boundary matching (example.co.uk
  blocks a.example.co.uk but not forexample.co.uk). Existing domains are
  untouched; only the add path is guarded.
- Save is per-line validated: invalid lines are skipped and reported by
  their 1-based line numbers in a flash banner, the rest are saved.
- Migration v5 seeds a default list (mozilla/github/aws/microsoft/apple/
  google/etc.) only when the settings key was never written, so admin
  edits or a full clear are never re-seeded.
- normalizeDomain is rewritten around explicit rules shared by the add
  path and the blocked list: lowercase, strip all trailing dots, reject
  any character outside [a-z0-9.-] (never silently strip — pasting a URL
  is an error), must end with a letter (excludes dotted-quad IPs), must
  contain a dot (no single-word names), each label non-empty/<=63 and
  starting+ending with a letter or digit (consecutive hyphens mid-label
  like xn--p1ai stay legal), total <= 253. No TLD validation.
- Tests: db round-trip + v5 seed (fresh/reopen/admin-edit/preexisting-key),
  matching semantics, parse/save partial validation, validation rules.
- docs: architecture.md blocked-domain section.
This commit is contained in:
bluebluesoda committed 2026-08-16 05:19:15 +00:00
1 parent fb3f8b7cfc
commit adad8584d4
13 files changed
+652 -28

No files matched your search

+15
View File
@@ -135,6 +135,21 @@ The panel daemon runs as the dedicated unprivileged `vps` system user
`vps config set net.v4_forward true`) regenerates every file from the DB and
deletes orphans, repairing any drift. All timestamps are stored as UTC; the admin
domain page renders them in the browser's timezone.
- **Blocked domains**: the admin domain panel keeps a blocked-domains list (a
`settings` key, `blocked_domains`, one domain per line, managed via a
textarea). `mgr.AddDomain` — the single add path for the user panel — refuses
a domain that exactly equals a blocked entry or is a subdomain of one (label
boundary: `example.co.uk` blocks `a.example.co.uk` but not
`forexample.co.uk`). No wildcards or regex; entries and additions share the
same normalization: lowercase, trailing dots stripped, only `[a-z0-9.-]`
allowed (anything else is a hard error — pasting a URL is rejected, not
silently rewritten), must end with a letter (which also excludes dotted-quad
IPs), must contain at least one dot (no single-word names), every label must
start/end with a letter or digit (consecutive hyphens mid-label like
`xn--p1ai` are fine), total ≤ 253 and label ≤ 63. Only the add
path is guarded — domains bound before an entry was blocked stay untouched.
The save handler validates each line and
reports invalid lines by their numbers while saving the rest.
- **Audit log**: resource-heavy user actions (power start/stop/restart,
reinstall, reset root password, domain config changes) are recorded in the
`audit_log` table (actor / action / UTC timestamp). The actor encodes who
+1
View File
@@ -63,6 +63,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/domains", s.requireAuth(s.handleDomains))
mux.HandleFunc("/domain-del", s.requireAuth(s.requirePost(s.handleDomainDel)))
mux.HandleFunc("/domain-update", s.requireAuth(s.requirePost(s.handleDomainUpdate)))
mux.HandleFunc("/blocked-domains", s.requireAuth(s.requirePost(s.handleBlockedDomains)))
mux.HandleFunc("/ipv6pool", s.requireAuth(s.handleIPv6Pool))
mux.HandleFunc("/ipv6pool-add", s.requireAuth(s.requirePost(s.handleIPv6PoolAdd)))
mux.HandleFunc("/ipv6pool-del", s.requireAuth(s.requirePost(s.handleIPv6PoolDel)))
+71
View File
@@ -530,3 +530,74 @@ func TestAdminPassChangeInvalidatesOtherSessions(t *testing.T) {
t.Fatalf("changer session overview = %d, want 200", rr.Code)
}
}
func TestBlockedDomainsSavePartial(t *testing.T) {
srv, _ := newTestServer(t)
setAdminPass(t, srv, "correct-horse-battery")
h := srv.Handler()
prefix := "/" + testAdminSecret
cookie := adminLogin(t, h, prefix, "correct-horse-battery")
// A mixed list: line 1 and 4 are invalid, the rest are saved.
rr := doReqOrigin(t, h, http.MethodPost, prefix+"/blocked-domains",
url.Values{"blocked": {"example.co.uk\nbad_domain\nok.example.com\nbad.com-"}},
cookie, "https://"+testHost)
if rr.Code != http.StatusFound {
t.Fatalf("blocked-domains save = %d, want 302", rr.Code)
}
got, err := srv.db.GetBlockedDomains()
if err != nil {
t.Fatal(err)
}
want := []string{"example.co.uk", "ok.example.com"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("saved list = %v, want %v", got, want)
}
// The flash banner names the skipped line numbers (2, 4).
rr = doReq(t, h, http.MethodPost, prefix+"/flash", nil, cookie)
if rr.Code != http.StatusOK {
t.Fatalf("flash = %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "2, 4") {
t.Errorf("flash does not name the bad lines: %s", rr.Body.String())
}
// The domains page pre-fills the textarea with the saved list.
rr = doReq(t, h, http.MethodGet, prefix+"/domains", nil, cookie)
if rr.Code != http.StatusOK {
t.Fatalf("GET /domains = %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "example.co.uk") || !strings.Contains(rr.Body.String(), "Blocked domains") {
t.Errorf("domains page missing blocked list textarea: %s", rr.Body.String())
}
}
func TestBlockedDomainsSaveAllValid(t *testing.T) {
srv, _ := newTestServer(t)
setAdminPass(t, srv, "correct-horse-battery")
h := srv.Handler()
prefix := "/" + testAdminSecret
cookie := adminLogin(t, h, prefix, "correct-horse-battery")
rr := doReqOrigin(t, h, http.MethodPost, prefix+"/blocked-domains",
url.Values{"blocked": {"a.com\nb.com"}}, cookie, "https://"+testHost)
if rr.Code != http.StatusFound {
t.Fatalf("save = %d, want 302", rr.Code)
}
got, err := srv.db.GetBlockedDomains()
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("saved list = %v", got)
}
// No skip-banner when every line is valid.
rr = doReq(t, h, http.MethodPost, prefix+"/flash", nil, cookie)
if rr.Code != http.StatusOK {
t.Fatalf("flash = %d", rr.Code)
}
if strings.Contains(rr.Body.String(), "skipped") {
t.Errorf("unexpected skip banner: %s", rr.Body.String())
}
}
+35 -1
View File
@@ -505,6 +505,7 @@ type domainsPageData struct {
Msg string
Err string
Domains []domainView
Blocked string // blocked-domains list, one domain per line (textarea)
Lang string
}
@@ -528,7 +529,7 @@ func (s *Server) renderDomains(w http.ResponseWriter, r *http.Request, d domains
}
// handleDomains renders the admin domain panel: every domain with its owner
// and last-modified time, newest first.
// and last-modified time, newest first, plus the blocked-domains list.
func (s *Server) handleDomains(w http.ResponseWriter, r *http.Request) {
d := domainsPageData{Title: "VPS Manager Admin — Domains", Prefix: s.prefix()}
all, err := s.mgr.AllDomains()
@@ -539,6 +540,14 @@ func (s *Server) handleDomains(w http.ResponseWriter, r *http.Request) {
d.Domains = append(d.Domains, domainView{Domain: x.Domain, Username: x.Username, UpdatedAt: x.UpdatedAt, ProxyProtocol: x.ProxyProtocol})
}
}
if blocked, err := s.db.GetBlockedDomains(); err != nil {
if d.Err != "" {
d.Err += " "
}
d.Err += err.Error()
} else {
d.Blocked = strings.Join(blocked, "\n")
}
s.renderDomains(w, r, d)
}
@@ -597,6 +606,31 @@ func (s *Server) handleDomainUpdate(w http.ResponseWriter, r *http.Request) {
s.redirect(w, r, s.p("/domains"), s.t(r, "domains_updated"))
}
// handleBlockedDomains saves the admin blocked-domains list from the textarea.
// Every line is validated individually: invalid lines are skipped (and
// reported by their 1-based line numbers in a flash banner), the valid lines
// are saved. The check lives in mgr.AddDomain, so all add paths are covered.
func (s *Server) handleBlockedDomains(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 400)
return
}
valid, bad := mgr.ParseBlockedList(r.FormValue("blocked"))
if err := s.db.SetBlockedDomains(valid); err != nil {
s.redirect(w, r, s.p("/domains"), "error: "+err.Error())
return
}
if len(bad) > 0 {
lines := make([]string, 0, len(bad))
for _, n := range bad {
lines = append(lines, strconv.Itoa(n))
}
s.redirect(w, r, s.p("/domains"), s.t(r, "blocked_skipped_lines", strings.Join(lines, ", ")))
return
}
s.redirect(w, r, s.p("/domains"), s.t(r, "blocked_saved"))
}
// ---- IPv6 pool management ----
type ipv6PoolPageData struct {
+18 -16
View File
@@ -79,22 +79,24 @@ func (s *Server) t(r *http.Request, key string, args ...any) string {
// stable identifiers; args are %-formatted into the chosen language's template.
func tr(l, key string, args ...any) string {
m := map[string][2]string{ // key -> [zh, en]
"err_bad_login": {"管理员密码错误", "invalid admin password"},
"err_too_many": {"尝试过于频繁,请 1 分钟后再试", "Too many attempts, please wait 1 minute"},
"err_not_configured": {"管理员尚未初始化 — 请运行 `vps admin-passwd`", "admin not configured yet — run `vps admin-passwd`"},
"err_pass_mismatch": {"两次输入的密码不一致", "The two passwords do not match"},
"err_pass_short": {"管理员密码至少需要 14 位", "admin password must be at least 14 characters"},
"user_created": {"用户已创建:\n%[1]v", "user created:\n%[1]v"},
"user_deleted": {"用户 %[1]v 已删除", "user %[1]v deleted"},
"quota_updated": {"已更新 %[1]v 的配额", "quotas updated for %[1]v"},
"power_ok": {"%[1]v %[2]v 成功", "%[2]v %[1]v ok"},
"new_panel_password": {"用户 %[1]v 的面板密码已重置:\n%[2]v\n面板:%[3]v", "%[1]v panel password reset:\n%[2]v\npanel: %[3]v"},
"admin_pass_changed": {"管理员密码已修改", "admin password changed"},
"err_invalid_disk": {"磁盘必须是整数 GiB", "disk must be an integer (GiB)"},
"domain_deleted": {"域名 %[1]v 已删除", "domain %[1]v deleted"},
"domains_updated": {"域名设置已保存", "domain settings saved"},
"pool_added": {"已添加 %[1]v 个 IPv6 地址到池", "added %[1]v IPv6 address(es) to the pool"},
"pool_removed": {"已从池中移除 %[1]v", "removed %[1]v from the pool"},
"err_bad_login": {"管理员密码错误", "invalid admin password"},
"err_too_many": {"尝试过于频繁,请 1 分钟后再试", "Too many attempts, please wait 1 minute"},
"err_not_configured": {"管理员尚未初始化 — 请运行 `vps admin-passwd`", "admin not configured yet — run `vps admin-passwd`"},
"err_pass_mismatch": {"两次输入的密码不一致", "The two passwords do not match"},
"err_pass_short": {"管理员密码至少需要 14 位", "admin password must be at least 14 characters"},
"user_created": {"用户已创建:\n%[1]v", "user created:\n%[1]v"},
"user_deleted": {"用户 %[1]v 已删除", "user %[1]v deleted"},
"quota_updated": {"已更新 %[1]v 的配额", "quotas updated for %[1]v"},
"power_ok": {"%[1]v %[2]v 成功", "%[2]v %[1]v ok"},
"new_panel_password": {"用户 %[1]v 的面板密码已重置:\n%[2]v\n面板:%[3]v", "%[1]v panel password reset:\n%[2]v\npanel: %[3]v"},
"admin_pass_changed": {"管理员密码已修改", "admin password changed"},
"err_invalid_disk": {"磁盘必须是整数 GiB", "disk must be an integer (GiB)"},
"domain_deleted": {"域名 %[1]v 已删除", "domain %[1]v deleted"},
"domains_updated": {"域名设置已保存", "domain settings saved"},
"blocked_saved": {"封禁域名列表已保存", "blocked-domains list saved"},
"blocked_skipped_lines": {"第 %[1]v 行不是有效域名,已跳过;其余已保存", "line(s) %[1]v are not valid domains and were skipped; the rest were saved"},
"pool_added": {"已添加 %[1]v 个 IPv6 地址到池", "added %[1]v IPv6 address(es) to the pool"},
"pool_removed": {"已从池中移除 %[1]v", "removed %[1]v from the pool"},
}
pair, ok := m[key]
if !ok {
@@ -93,6 +93,17 @@ code { background: #f3f4f6; padding: 1px 6px; border-radius: 5px; font-size: 12p
<div class="empty">{{if eq .Lang "zh"}}暂无域名{{else}}No domains{{end}}</div>
{{end}}
</div>
<div class="card">
<h2>{{if eq .Lang "zh"}}禁止添加的域名{{else}}Blocked domains{{end}}</h2>
<p class="muted">{{if eq .Lang "zh"}}此处封禁的域名本身及其所有子域名都无法被添加,不影响系统中已有的域名。每行一个域名,只需输入域名本身(不支持通配符)。例如 example.co.uk 会封禁 example.co.uk、a.example.co.uk、a.b.c.example.co.uk,但不会封禁 forexample.co.uk。{{else}}Domains listed here — and all of their subdomains — cannot be added anymore; existing domains are unaffected. One domain per line, just the bare domain (no wildcards). E.g. example.co.uk blocks example.co.uk, a.example.co.uk and a.b.c.example.co.uk, but not forexample.co.uk.{{end}}</p>
<form action="{{.Prefix}}/blocked-domains" method="post">
<textarea name="blocked" rows="6" spellcheck="false" style="width:100%; font-family:monospace; font-size:13px; border-radius:8px; border:1px solid #d1d5db; padding:8px; color-scheme:light dark;">{{.Blocked}}</textarea>
<div class="savebar">
<button class="primary" type="submit">{{if eq .Lang "zh"}}保存{{else}}Save{{end}}</button>
</div>
</form>
</div>
</main>
<script>
var P='{{.Prefix}}';
+18
View File
@@ -0,0 +1,18 @@
package db
// DefaultBlockedDomains is the initial admin blocked-domains list, seeded once
// by migration v5 (settings key absent only). The admin edits/removes entries
// in the web UI; that state is never re-seeded.
var DefaultBlockedDomains = []string{
"mozilla.org",
"mozilla.net",
"github.com",
"githubusercontent.com",
"github.io",
"awsstatic.com",
"microsoft.com",
"apple.com",
"cdn-apple.com",
"icloud.com",
"google.com",
}
+161
View File
@@ -0,0 +1,161 @@
package db
import (
"database/sql"
"path/filepath"
"reflect"
"testing"
_ "modernc.org/sqlite"
)
func TestBlockedDomainsRoundTrip(t *testing.T) {
d, err := Open(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer d.Close()
// A fresh DB carries the seeded defaults (migration v5); clear them first
// so the test starts from a known, empty list.
if err := d.SetBlockedDomains(nil); err != nil {
t.Fatal(err)
}
got, err := d.GetBlockedDomains()
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("after clear: got %v, want empty", got)
}
list := []string{"example.co.uk", "spam.example", "ads.io"}
if err := d.SetBlockedDomains(list); err != nil {
t.Fatal(err)
}
got, err = d.GetBlockedDomains()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, list) {
t.Errorf("round trip = %v, want %v", got, list)
}
// Overwrite with fewer entries: the old ones must not linger.
if err := d.SetBlockedDomains([]string{"only.com"}); err != nil {
t.Fatal(err)
}
got, err = d.GetBlockedDomains()
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0] != "only.com" {
t.Errorf("after overwrite = %v, want [only.com]", got)
}
// Empty list clears the value.
if err := d.SetBlockedDomains(nil); err != nil {
t.Fatal(err)
}
got, err = d.GetBlockedDomains()
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("after clear = %v, want empty", got)
}
}
// TestBlockedDomainsSeededOnOpen verifies migration v5 seeds the default list
// on a fresh database and that a second open does not duplicate or re-seed it.
func TestBlockedDomainsSeededOnOpen(t *testing.T) {
path := filepath.Join(t.TempDir(), "seed.db")
d1, err := Open(path)
if err != nil {
t.Fatal(err)
}
got, err := d1.GetBlockedDomains()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, DefaultBlockedDomains) {
t.Errorf("seeded list = %v, want defaults %v", got, DefaultBlockedDomains)
}
d1.Close()
d2, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer d2.Close()
got, err = d2.GetBlockedDomains()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, DefaultBlockedDomains) {
t.Errorf("after reopen = %v, want unchanged defaults", got)
}
}
// TestBlockedDomainsSeedRespectsAdminEdits verifies an admin's edit (and a
// full clear) survives a restart — migration v5 never re-seeds an existing key.
func TestBlockedDomainsSeedRespectsAdminEdits(t *testing.T) {
path := filepath.Join(t.TempDir(), "edit.db")
d1, err := Open(path)
if err != nil {
t.Fatal(err)
}
if err := d1.SetBlockedDomains([]string{"only.me"}); err != nil {
t.Fatal(err)
}
d1.Close()
d2, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer d2.Close()
got, err := d2.GetBlockedDomains()
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0] != "only.me" {
t.Errorf("after reopen = %v, want [only.me]", got)
}
}
// TestBlockedDomainsSeedSkipsPreExistingKey simulates the real upgrade: a
// v4-era database that already carries a blocked_domains value must not have
// the defaults imposed over it.
func TestBlockedDomainsSeedSkipsPreExistingKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "v4.db")
raw, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
for _, s := range []string{
`CREATE TABLE settings(key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
`CREATE TABLE schema_migrations(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)`,
`INSERT INTO schema_migrations(version, applied_at) VALUES(1,'x'),(2,'x'),(3,'x'),(4,'x')`,
`INSERT INTO settings(key, value) VALUES('` + SettingBlockedDomains + `', 'custom.only')`,
} {
if _, err := raw.Exec(s); err != nil {
raw.Close()
t.Fatalf("seed v4 db: %v", err)
}
}
raw.Close()
d, err := Open(path)
if err != nil {
t.Fatalf("open v4 db: %v", err)
}
defer d.Close()
got, err := d.GetBlockedDomains()
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0] != "custom.only" {
t.Errorf("pre-existing list = %v, want [custom.only] (defaults must not be imposed)", got)
}
}
+13 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/url"
"os"
"strings"
"time"
_ "modernc.org/sqlite"
@@ -54,7 +55,7 @@ func (d *DB) Close() error { return d.sql.Close() }
// schemaVersion is the current schema version. Every migration in
// migrations must be applied in order; Open refuses to start on a database
// whose version is newer than this binary understands (downgrade protection).
const schemaVersion = 4
const schemaVersion = 5
// migrations are applied in order, each inside its own transaction. v1 is the
// original schema (baseline); later versions only add/alter, never drop.
@@ -137,8 +138,19 @@ var migrations = []struct {
`ALTER TABLE users ADD COLUMN ipv6_address TEXT`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_ipv6 ON users(ipv6_address)`,
}},
// v5: seed the default admin blocked-domains list. The INSERT...SELECT
// only fires when the settings key was never written, so an admin's later
// edits (or a full clear) are never re-seeded on restart.
{5, []string{
`INSERT INTO settings(key, value)
SELECT '` + SettingBlockedDomains + `', '` + sqliteStr(strings.Join(DefaultBlockedDomains, "\n")) + `'
WHERE NOT EXISTS (SELECT 1 FROM settings WHERE key = '` + SettingBlockedDomains + `')`,
}},
}
// sqliteStr quotes a string literal for SQLite by doubling any single quote.
func sqliteStr(s string) string { return strings.ReplaceAll(s, "'", "''") }
// userStatus values (kept as plain strings in the DB).
const (
StatusReady = "ready"
+24
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
)
// Settings keys. The settings table is the DB's key/value store for panel
@@ -28,6 +29,11 @@ const (
// mgr.ApplyV4State; the panel reads it live to decide whether domains may
// be added.
SettingV4Forward = "v4_forward"
// SettingBlockedDomains is the admin blocked-domains list, stored as
// newline-separated normalized domains. A blocked domain and all its
// subdomains are refused by AddDomain (admin-managed via the web UI).
SettingBlockedDomains = "blocked_domains"
)
// GetSetting returns a settings value; ok is false when the key is absent.
@@ -53,3 +59,21 @@ func (d *DB) SetSetting(key, value string) error {
}
return nil
}
// GetBlockedDomains returns the admin blocked-domains list (already
// normalized, no empty entries). An absent or empty key yields an empty list.
func (d *DB) GetBlockedDomains() ([]string, error) {
v, ok, err := d.GetSetting(SettingBlockedDomains)
if err != nil {
return nil, err
}
if !ok || v == "" {
return nil, nil
}
return strings.Split(v, "\n"), nil
}
// SetBlockedDomains persists the blocked-domains list, one domain per line.
func (d *DB) SetBlockedDomains(list []string) error {
return d.SetSetting(SettingBlockedDomains, strings.Join(list, "\n"))
}
+55
View File
@@ -0,0 +1,55 @@
package mgr
import "strings"
// DomainBlocked reports whether the already-normalized domain d is refused by
// the admin blocked-domains list: it is blocked when it exactly equals a
// blocked domain or is a subdomain of one (label boundary — "example.co.uk"
// matches "example.co.uk", "a.example.co.uk" and "a.b.c.example.co.uk" but
// not "forexample.co.uk"). Returns the matching blocked domain, or "" when
// the add is allowed. The list is read fresh from the DB on every call so a
// change made in the admin panel applies immediately.
func (m *Manager) DomainBlocked(d string) (string, error) {
list, err := m.db.GetBlockedDomains()
if err != nil {
return "", err
}
for _, b := range list {
if d == b || strings.HasSuffix(d, "."+b) {
return b, nil
}
}
return "", nil
}
// ParseBlockedList validates a textarea value (one domain per line, no
// wildcards) and returns the normalized, deduplicated entries plus the
// 1-based line numbers that were invalid. Empty and whitespace-only lines are
// ignored, not counted as errors. Invalid lines are skipped; the rest are
// usable as the new list (partial saves, reported by line number).
func ParseBlockedList(text string) (valid []string, badLines []int) {
for i, ln := range strings.Split(text, "\n") {
ln = strings.TrimSpace(ln)
if ln == "" {
continue
}
n, err := normalizeDomain(ln)
if err != nil {
badLines = append(badLines, i+1)
continue
}
if !containsStr(valid, n) {
valid = append(valid, n)
}
}
return valid, badLines
}
func containsStr(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
+179
View File
@@ -0,0 +1,179 @@
package mgr
import (
"strings"
"testing"
)
func TestParseBlockedList(t *testing.T) {
cases := []struct {
in string
wantValid []string
wantBad []int
}{
{"", nil, nil},
{" \n\n ", nil, nil}, // whitespace-only lines ignored
{"example.co.uk", []string{"example.co.uk"}, nil}, // plain domain
{" Example.CO.UK ", []string{"example.co.uk"}, nil}, // trimmed + lowercased
{"example.co.uk.", []string{"example.co.uk"}, nil}, // trailing dot stripped
{"example.co.uk\nb.com", []string{"example.co.uk", "b.com"}, nil},
{"example.co.uk\nexample.co.uk", []string{"example.co.uk"}, nil}, // deduped
{"bad_domain", nil, []int{1}}, // underscore rejected
{"-bad.com\nok.com\nbad..com", []string{"ok.com"}, []int{1, 3}}, // empty label reported by number
}
for _, c := range cases {
valid, bad := ParseBlockedList(c.in)
if !eqStrings(valid, c.wantValid) {
t.Errorf("ParseBlockedList(%q) valid = %v, want %v", c.in, valid, c.wantValid)
}
if !eqInts(bad, c.wantBad) {
t.Errorf("ParseBlockedList(%q) bad = %v, want %v", c.in, bad, c.wantBad)
}
}
}
func TestDomainBlockedMatching(t *testing.T) {
m, d, _ := setupDomainTest(t, t.TempDir())
if err := d.SetBlockedDomains([]string{"example.co.uk", "ads.io"}); err != nil {
t.Fatal(err)
}
blocked := []string{
"example.co.uk", // exact
"a.example.co.uk", // one label
"a.b.c.example.co.uk", // deep subdomain
"www.ads.io",
}
allowed := []string{
"forexample.co.uk", // shared suffix, not a subdomain
"example.com", // different TLD
"co.uk", // a parent is fine when not listed
"notexample.co.uk",
}
for _, s := range blocked {
if b, err := m.DomainBlocked(s); err != nil || b == "" {
t.Errorf("DomainBlocked(%q) = %q, %v; want blocked", s, b, err)
}
}
for _, s := range allowed {
if b, err := m.DomainBlocked(s); err != nil || b != "" {
t.Errorf("DomainBlocked(%q) = %q, %v; want allowed", s, b, err)
}
}
// The matching entry is reported back.
if b, err := m.DomainBlocked("a.b.example.co.uk"); err != nil || b != "example.co.uk" {
t.Errorf("DomainBlocked(sub) = %q, %v; want example.co.uk", b, err)
}
}
func TestAddDomainRefusesBlocked(t *testing.T) {
m, d, name := setupDomainTest(t, t.TempDir())
if err := d.SetBlockedDomains([]string{"example.co.uk"}); err != nil {
t.Fatal(err)
}
for _, s := range []string{"example.co.uk", "a.example.co.uk"} {
if err := m.AddDomain(name, s, false); err == nil || !strings.Contains(err.Error(), "blocked") {
t.Errorf("AddDomain(%q) err = %v, want blocked-domain error", s, err)
}
}
if _, err := d.GetDomainByDomain("example.co.uk"); err == nil {
t.Fatal("blocked domain was inserted into the DB")
}
// An unrelated domain still works.
if err := m.AddDomain(name, "ok.example.com", false); err != nil {
t.Fatalf("AddDomain(unblocked) = %v", err)
}
// A domain bound before the list was set stays bound — the blocklist only
// guards the add path, never touches existing rows.
if err := d.SetBlockedDomains([]string{"ok.example.com"}); err != nil {
t.Fatal(err)
}
if _, err := d.GetDomainByDomain("ok.example.com"); err != nil {
t.Fatalf("existing domain lost after blocking it: %v", err)
}
}
func TestNormalizeDomainRules(t *testing.T) {
invalid := []string{
"bad..com", // consecutive dots
"a.b..com", // consecutive dots
".a.b.com", // leading dot
"a-.b.com", // hyphen glued to a dot
"-a.com", // leading hyphen
"a.b.com-", // trailing hyphen
"1.2.3.4", // dotted-quad IPv4 (ends with a digit)
"https://example.com", // URL pasted in — rejected, not silently rewritten
"my_domain.com", // underscore
"example", // single word, no dot
"localhost", // single word, no dot
"a..b", // consecutive dots
}
for _, bad := range invalid {
if _, err := normalizeDomain(bad); err == nil {
t.Errorf("normalizeDomain(%q): expected error", bad)
}
}
valid := []string{
"example.com",
"a.b.example.co.uk",
"a1b.example.com", // digits mid-label
"a-b.example.com", // hyphen mid-label
"a--b.example.com", // consecutive hyphens are legal per RFC 1035
"xn--p1ai.example.com", // punycode prefix
"x.com", // single-letter label
"1.com", // leading-digit label is fine now
"a-1.b.com", // digit next to hyphen is fine
"1.2.example.com", // numeric subdomain labels
"example.a", // no TLD validation
}
for _, ok := range valid {
if _, err := normalizeDomain(ok); err != nil {
t.Errorf("normalizeDomain(%q) = %v, want nil", ok, err)
}
}
// Trailing dots — one or many — are normalized away.
for _, in := range []string{"example.com.", "example.com..", "example.com..."} {
if got, err := normalizeDomain(in); err != nil || got != "example.com" {
t.Errorf("normalizeDomain(%q) = %q, %v; want example.com, nil", in, got, err)
}
}
// Length limits: a 64-char label and a >253 total are both rejected.
longLabel := strings.Repeat("a", 64) + ".com"
if _, err := normalizeDomain(longLabel); err == nil {
t.Error("normalizeDomain(64-char label): expected error")
}
longDomain := strings.Repeat("a.", 130) + "com" // 261 chars
if _, err := normalizeDomain(longDomain); err == nil {
t.Error("normalizeDomain(>253 chars): expected error")
}
// The add path shares the same rules.
m, _, name := setupDomainTest(t, t.TempDir())
for _, bad := range []string{"bad..com", ".a.b.com", "1.2.3.4", "https://example.com"} {
if err := m.AddDomain(name, bad, false); err == nil {
t.Errorf("AddDomain(%q) should fail", bad)
}
}
}
func eqStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func eqInts(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+51 -10
View File
@@ -1086,6 +1086,14 @@ func (m *Manager) AddDomain(name, domain string, proxyProtocol bool) error {
if err != nil {
return err
}
// Refuse domains on the admin blocked list (the entry and all its
// subdomains). Read fresh on every add so panel edits apply immediately;
// a list that cannot be read fails closed — never silently allow.
if blockedBy, err := m.DomainBlocked(domain); err != nil {
return err
} else if blockedBy != "" {
return fmt.Errorf("domain %q is blocked (blocks %q and all its subdomains)", domain, blockedBy)
}
if exists, err := m.db.DomainExists(domain); err != nil {
return err
} else if exists {
@@ -1239,27 +1247,60 @@ func (m *Manager) EnsureBlockRoutes() error {
return firstErr
}
// domainRe allows only lowercase letters, digits, dots and hyphens, starting
// with an alphanumeric. The trailing letter check is applied separately.
var domainRe = regexp.MustCompile(`^[a-z0-9][a-z0-9.-]*$`)
// normalizeDomain validates and normalizes a domain for use in Traefik
// dynamic YAML. Strictly limited to [a-z0-9.-] and must end with a letter so
// it cannot break YAML syntax or inject extra Traefik config.
// dynamic YAML. Rules:
// 1. lowercase, strip surrounding whitespace and ALL trailing dots
// 2. keep only [a-z0-9.-] — any other character is a hard error (never
// silently stripped), so pasting "https://example.com" or a path is
// rejected instead of being quietly rewritten
// 3. must end with a letter (this also excludes every dotted-quad IPv4)
// 4. must contain at least one dot (no single-word names)
// 5. every label must start and end with a letter or digit (dots can only be
// between labels, hyphens never at a label's edge — consecutive hyphens
// inside a label like "a--b" or "xn--p1ai" are fine)
// 6. total length ≤ 253, every label ≤ 63
//
// No TLD validation. The resulting string is safe to drop into YAML and as a
// filename (dots/hyphens only).
func normalizeDomain(d string) (string, error) {
d = strings.TrimSpace(strings.ToLower(d))
d = strings.TrimSuffix(d, ".")
d = strings.TrimRight(d, ".")
if d == "" {
return "", errors.New("domain empty")
}
if len(d) > 253 {
return "", errors.New("domain too long")
return "", errors.New("domain too long (max 253 characters)")
}
if !domainRe.MatchString(d) {
return "", errors.New("invalid domain: only letters, digits, dots and hyphens allowed")
for i := 0; i < len(d); i++ {
if !isDomainChar(d[i]) && d[i] != '.' && d[i] != '-' {
return "", errors.New("invalid domain: only lowercase letters, digits, dots and hyphens are allowed")
}
}
if !strings.Contains(d, ".") {
return "", errors.New("invalid domain: must be a full domain name (e.g. example.com), not a single word")
}
if last := d[len(d)-1]; last < 'a' || last > 'z' {
return "", errors.New("invalid domain: must end with a letter")
}
// Every label must be non-empty and start/end with a letter or digit.
// This rejects leading/trailing/consecutive dots and any hyphen at a
// label's edge, while allowing consecutive hyphens mid-label.
for _, label := range strings.Split(d, ".") {
if label == "" {
return "", errors.New("invalid domain: empty label (leading or consecutive dot)")
}
if len(label) > 63 {
return "", errors.New("invalid domain: each label is limited to 63 characters")
}
if !isDomainChar(label[0]) || !isDomainChar(label[len(label)-1]) {
return "", errors.New("invalid domain: each label must start and end with a letter or digit")
}
}
return d, nil
}
// isDomainChar reports whether b is a lowercase letter or a digit — the only
// characters allowed at a label's first and last position.
func isDomainChar(b byte) bool {
return b >= 'a' && b <= 'z' || b >= '0' && b <= '9'
}