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