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:
1 parent
fb3f8b7cfc
commit
adad8584d4
13 files changed
+652
-28
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}}';
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
}
|
||||
Reference in new issue
Block a user