feat(core): improve CNAME binding logic with CAS and update hostname validation
This commit is contained in:
@@ -18,6 +18,9 @@ This project focuses on providing a high-performance, secure, and extensible Git
|
||||
- [x] **Programmable**: Extensible logic using JavaScript (Goja).
|
||||
- [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
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
- [x] **可编程**: 使用 JavaScript (Goja) 编写自定义路由逻辑。
|
||||
- [x] **反向代理**: 支持将请求代理到后端服务。
|
||||
|
||||
> [!WARNING]
|
||||
> **安全提示**: 本项目设计用于自托管或私有环境。它不对 CNAME 别名进行域名所有权验证。在多用户环境中,用户可能会通过在 `.pages.yaml` 中声明他人域名来实施“劫持”。
|
||||
|
||||
|
||||
## Get Started
|
||||
|
||||
|
||||
2
go.mod
2
go.mod
@@ -15,7 +15,7 @@ require (
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/stretchr/testify v1.11.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
|
||||
)
|
||||
|
||||
|
||||
2
go.sum
2
go.sum
@@ -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-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-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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -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 {
|
||||
oldDomains := make([]string, 0)
|
||||
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)
|
||||
}
|
||||
for _, oldDomain := range oldDomains {
|
||||
if err := a.Unbind(ctx, oldDomain); err != nil {
|
||||
|
||||
var oldDomains []string
|
||||
domainsRaw, _ := json.Marshal(domains)
|
||||
|
||||
for {
|
||||
success, err := a.config.PutIfNotExists(ctx, rKey, string(domainsRaw), kv.TTLKeep)
|
||||
if err != nil {
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
@@ -53,12 +87,8 @@ func (a *DomainAlias) Bind(ctx context.Context, domains []string, owner, repo st
|
||||
Repo: repo,
|
||||
}
|
||||
aliasMetaRaw, _ := json.Marshal(aliasMeta)
|
||||
domainsRaw, _ := json.Marshal(domains)
|
||||
_ = a.config.Put(ctx, rKey, string(domainsRaw), kv.TTLKeep)
|
||||
for _, domain := range domains {
|
||||
if err := a.config.Put(ctx, domain, string(aliasMetaRaw), kv.TTLKeep); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = a.config.Put(ctx, domain, string(aliasMetaRaw), kv.TTLKeep)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ func (s *ServerMeta) parsePageConfig(ctx context.Context, meta *PageMetaContent,
|
||||
cname, err := vfs.ReadString(ctx, "CNAME")
|
||||
if cname != "" && err == nil {
|
||||
cname = strings.TrimSpace(cname)
|
||||
if al, ok := s.aliasCheck(cname); ok {
|
||||
if al, ok := s.AliasCheck(cname); ok {
|
||||
alias = append(alias, al)
|
||||
} else {
|
||||
return fmt.Errorf("invalid alias %s", cname)
|
||||
@@ -233,7 +233,7 @@ func (s *ServerMeta) parsePageConfig(ctx context.Context, meta *PageMetaContent,
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if al, ok := s.aliasCheck(item); ok {
|
||||
if al, ok := s.AliasCheck(item); ok {
|
||||
alias = append(alias, al)
|
||||
} else {
|
||||
return fmt.Errorf("invalid alias %s", item)
|
||||
@@ -269,9 +269,9 @@ func (s *ServerMeta) parsePageConfig(ctx context.Context, meta *PageMetaContent,
|
||||
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)
|
||||
if !regexpHostname.MatchString(cname) {
|
||||
return "", false
|
||||
|
||||
69
tests/cname_test.go
Normal file
69
tests/cname_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user