feat(core): improve CNAME binding logic with CAS and update hostname validation

This commit is contained in:
ExplodingDragon
2026-02-01 00:49:08 +08:00
parent b4c0ae11df
commit 57e07b3825
7 changed files with 123 additions and 16 deletions

View File

@@ -18,6 +18,9 @@ This project focuses on providing a high-performance, secure, and extensible Git
- [x] **Programmable**: Extensible logic using JavaScript (Goja). - [x] **Programmable**: Extensible logic using JavaScript (Goja).
- [x] **Reverse Proxy**: Support for proxying requests to backends. - [x] **Reverse Proxy**: Support for proxying requests to backends.
> [!WARNING]
> **Security Note**: This project is designed for self-hosted/private environments. It does not perform domain ownership verification for CNAME aliases. In a multi-user environment, users could potentially "hijack" domains by claiming them in their `.pages.yaml`.
## Get Started ## Get Started

View File

@@ -18,6 +18,9 @@
- [x] **可编程**: 使用 JavaScript (Goja) 编写自定义路由逻辑。 - [x] **可编程**: 使用 JavaScript (Goja) 编写自定义路由逻辑。
- [x] **反向代理**: 支持将请求代理到后端服务。 - [x] **反向代理**: 支持将请求代理到后端服务。
> [!WARNING]
> **安全提示**: 本项目设计用于自托管或私有环境。它不对 CNAME 别名进行域名所有权验证。在多用户环境中,用户可能会通过在 `.pages.yaml` 中声明他人域名来实施“劫持”。
## Get Started ## Get Started

2
go.mod
View File

@@ -15,7 +15,7 @@ require (
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
gopkg.d7z.net/middleware v0.0.0-20260131134426-cea18952b028 gopkg.d7z.net/middleware v0.0.0-20260131162733-c737c5341584
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )

2
go.sum
View File

@@ -168,6 +168,8 @@ gopkg.d7z.net/middleware v0.0.0-20260131122058-3c200930af2d h1:nNXZgc02tab2+WuAn
gopkg.d7z.net/middleware v0.0.0-20260131122058-3c200930af2d/go.mod h1:TDqvtfgaXzOvm9gbG8t5FF0AKSKve8pcE9uBVix+1pU= gopkg.d7z.net/middleware v0.0.0-20260131122058-3c200930af2d/go.mod h1:TDqvtfgaXzOvm9gbG8t5FF0AKSKve8pcE9uBVix+1pU=
gopkg.d7z.net/middleware v0.0.0-20260131134426-cea18952b028 h1:BPm7q2ys8IPHAQe01HBSkYH+2itXuP6DvVPZlg45tM4= gopkg.d7z.net/middleware v0.0.0-20260131134426-cea18952b028 h1:BPm7q2ys8IPHAQe01HBSkYH+2itXuP6DvVPZlg45tM4=
gopkg.d7z.net/middleware v0.0.0-20260131134426-cea18952b028/go.mod h1:TDqvtfgaXzOvm9gbG8t5FF0AKSKve8pcE9uBVix+1pU= gopkg.d7z.net/middleware v0.0.0-20260131134426-cea18952b028/go.mod h1:TDqvtfgaXzOvm9gbG8t5FF0AKSKve8pcE9uBVix+1pU=
gopkg.d7z.net/middleware v0.0.0-20260131162733-c737c5341584 h1:Bdtk/GJQELmfVeoAEyCAUUzR90ZOx6qGA/M3Yjf09Ao=
gopkg.d7z.net/middleware v0.0.0-20260131162733-c737c5341584/go.mod h1:TDqvtfgaXzOvm9gbG8t5FF0AKSKve8pcE9uBVix+1pU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -35,16 +35,50 @@ func (a *DomainAlias) Query(ctx context.Context, domain string) (*Alias, error)
} }
func (a *DomainAlias) Bind(ctx context.Context, domains []string, owner, repo string) error { func (a *DomainAlias) Bind(ctx context.Context, domains []string, owner, repo string) error {
oldDomains := make([]string, 0)
rKey := base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("%s/%s", owner, repo))) rKey := base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("%s/%s", owner, repo)))
if oldStr, err := a.config.Get(ctx, rKey); err == nil {
_ = json.Unmarshal([]byte(oldStr), &oldDomains) var oldDomains []string
} domainsRaw, _ := json.Marshal(domains)
for _, oldDomain := range oldDomains {
if err := a.Unbind(ctx, oldDomain); err != nil { for {
success, err := a.config.PutIfNotExists(ctx, rKey, string(domainsRaw), kv.TTLKeep)
if err != nil {
return err return err
} }
if success {
oldDomains = []string{}
break
}
oldStr, err := a.config.Get(ctx, rKey)
if err != nil {
continue
}
if err = json.Unmarshal([]byte(oldStr), &oldDomains); err != nil {
oldDomains = []string{}
}
success, err = a.config.CompareAndSwap(ctx, rKey, oldStr, string(domainsRaw))
if err != nil {
return err
}
if success {
break
}
} }
newDomainsMap := make(map[string]bool)
for _, d := range domains {
newDomainsMap[d] = true
}
for _, oldDomain := range oldDomains {
if !newDomainsMap[oldDomain] {
_ = a.Unbind(ctx, oldDomain)
}
}
if len(domains) == 0 { if len(domains) == 0 {
return nil return nil
} }
@@ -53,12 +87,8 @@ func (a *DomainAlias) Bind(ctx context.Context, domains []string, owner, repo st
Repo: repo, Repo: repo,
} }
aliasMetaRaw, _ := json.Marshal(aliasMeta) aliasMetaRaw, _ := json.Marshal(aliasMeta)
domainsRaw, _ := json.Marshal(domains)
_ = a.config.Put(ctx, rKey, string(domainsRaw), kv.TTLKeep)
for _, domain := range domains { for _, domain := range domains {
if err := a.config.Put(ctx, domain, string(aliasMetaRaw), kv.TTLKeep); err != nil { _ = a.config.Put(ctx, domain, string(aliasMetaRaw), kv.TTLKeep)
return err
}
} }
return nil return nil
} }

View File

@@ -201,7 +201,7 @@ func (s *ServerMeta) parsePageConfig(ctx context.Context, meta *PageMetaContent,
cname, err := vfs.ReadString(ctx, "CNAME") cname, err := vfs.ReadString(ctx, "CNAME")
if cname != "" && err == nil { if cname != "" && err == nil {
cname = strings.TrimSpace(cname) cname = strings.TrimSpace(cname)
if al, ok := s.aliasCheck(cname); ok { if al, ok := s.AliasCheck(cname); ok {
alias = append(alias, al) alias = append(alias, al)
} else { } else {
return fmt.Errorf("invalid alias %s", cname) return fmt.Errorf("invalid alias %s", cname)
@@ -233,7 +233,7 @@ func (s *ServerMeta) parsePageConfig(ctx context.Context, meta *PageMetaContent,
if item == "" { if item == "" {
continue continue
} }
if al, ok := s.aliasCheck(item); ok { if al, ok := s.AliasCheck(item); ok {
alias = append(alias, al) alias = append(alias, al)
} else { } else {
return fmt.Errorf("invalid alias %s", item) return fmt.Errorf("invalid alias %s", item)
@@ -269,9 +269,9 @@ func (s *ServerMeta) parsePageConfig(ctx context.Context, meta *PageMetaContent,
return nil return nil
} }
var regexpHostname = regexp.MustCompile(`^(?:([a-z0-9-]+|\*)\.)?([a-z0-9-]{1,61})\.([a-z0-9]{2,7})$`) var regexpHostname = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,18}$`)
func (s *ServerMeta) aliasCheck(cname string) (string, bool) { func (s *ServerMeta) AliasCheck(cname string) (string, bool) {
cname = strings.TrimSpace(cname) cname = strings.TrimSpace(cname)
if !regexpHostname.MatchString(cname) { if !regexpHostname.MatchString(cname) {
return "", false return "", false

69
tests/cname_test.go Normal file
View File

@@ -0,0 +1,69 @@
package tests
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"gopkg.d7z.net/gitea-pages/pkg/core"
"gopkg.d7z.net/middleware/kv"
)
func TestCNAMERegex(t *testing.T) {
db, _ := kv.NewMemory("")
meta := core.NewServerMeta(nil, nil, "example.com", nil, db, 0, 0, 0)
tests := []struct {
domain string
valid bool
}{
{"a.com", true},
{"sub.a.com", true},
{"a.b.c.d.com", true},
{"invalid_name.com", false},
{"-start.com", false},
{"end-.com", false},
{"a.com-too-long-tld-xxxxxxxxxxxxxxxxxxx", false},
}
for _, tt := range tests {
_, ok := meta.AliasCheck(tt.domain)
assert.Equal(t, tt.valid, ok, "Testing domain: %s", tt.domain)
}
}
func TestAliasBindCAS(t *testing.T) {
db, _ := kv.NewMemory("")
alias := core.NewDomainAlias(db)
ctx := context.Background()
err := alias.Bind(ctx, []string{"a.com", "b.com"}, "owner1", "repo1")
assert.NoError(t, err)
a, err := alias.Query(ctx, "a.com")
assert.NoError(t, err)
if assert.NotNil(t, a) {
assert.Equal(t, "owner1", a.Owner)
}
err = alias.Bind(ctx, []string{"b.com", "c.com"}, "owner1", "repo1")
assert.NoError(t, err)
_, err = alias.Query(ctx, "a.com")
assert.Error(t, err)
a, err = alias.Query(ctx, "c.com")
assert.NoError(t, err)
if assert.NotNil(t, a) {
assert.Equal(t, "owner1", a.Owner)
}
}