Add a relaxed-idna feature to allow some uses of _ in hostnames.

This is added to aid migration from Codeberg Pages v2. Forgejo allows
both `_` and `-` in usernames, and it is necessary to be able to accept
host names like `user_name.codeberg.page` under a wildcard domain.
(It is not possible to get a TLS certificate for a host name like this,
so only a wildcard certificate will be able to cover it.)
This commit is contained in:
Catherine
2025-12-12 02:24:31 +00:00
parent 86845f2505
commit c88d04c71b
3 changed files with 75 additions and 4 deletions

View File

@@ -28,6 +28,9 @@ jobs:
- name: Build service
run: |
go build
- name: Run tests
run: |
go test ./src
- name: Run static analysis
run: |
go vet

View File

@@ -54,12 +54,25 @@ func GetHost(r *http.Request) (string, error) {
// this also rejects invalid characters and labels
host, err = idnaProfile.ToASCII(host)
if err != nil {
return "", AuthError{http.StatusBadRequest,
fmt.Sprintf("malformed host name %q", host)}
if config.Feature("relaxed-idna") {
// unfortunately, the go IDNA library has some significant issues around its
// Unicode TR46 implementation: https://github.com/golang/go/issues/76804
// we would like to allow *just* the _ here, but adding `idna.StrictDomainName(false)`
// would also accept domains like `*.foo.bar` which should clearly be disallowed.
// as a workaround, accept a domain name if it is valid with all `_` characters
// replaced with an alphanumeric character (we use `a`); this allows e.g. `foo_bar.xxx`
// and `foo__bar.xxx`, as well as `_foo.xxx` and `foo_.xxx`. labels starting with
// an underscore are explicitly rejected below.
_, err = idnaProfile.ToASCII(strings.ReplaceAll(host, "_", "a"))
}
if err != nil {
return "", AuthError{http.StatusBadRequest,
fmt.Sprintf("malformed host name %q", host)}
}
}
if strings.HasPrefix(host, ".") {
if strings.HasPrefix(host, ".") || strings.HasPrefix(host, "_") {
return "", AuthError{http.StatusBadRequest,
fmt.Sprintf("host name %q is reserved", host)}
fmt.Sprintf("reserved host name %q", host)}
}
host = strings.TrimSuffix(host, ".")
return host, nil

55
src/pages_test.go Normal file
View File

@@ -0,0 +1,55 @@
package git_pages
import (
"net/http"
"strings"
"testing"
)
func checkHost(t *testing.T, host string, expectOk string, expectErr string) {
host, err := GetHost(&http.Request{Host: host})
if expectErr != "" {
if err == nil || !strings.HasPrefix(err.Error(), expectErr) {
t.Errorf("%s: expect err %s, got err %s", host, expectErr, err)
}
}
if expectOk != "" {
if err != nil {
t.Errorf("%s: expect ok %s, got err %s", host, expectOk, err)
} else if host != expectOk {
t.Errorf("%s: expect ok %s, got ok %s", host, expectOk, host)
}
}
}
func TestHelloName(t *testing.T) {
config = &Config{Features: []string{}}
checkHost(t, "foo.bar", "foo.bar", "")
checkHost(t, "foo-baz.bar", "foo-baz.bar", "")
checkHost(t, "foo--baz.bar", "foo--baz.bar", "")
checkHost(t, "foo.bar.", "foo.bar", "")
checkHost(t, ".foo.bar", "", "reserved host name")
checkHost(t, "..foo.bar", "", "reserved host name")
checkHost(t, "ß.bar", "xn--zca.bar", "")
checkHost(t, "xn--zca.bar", "xn--zca.bar", "")
checkHost(t, "foo-.bar", "", "malformed host name")
checkHost(t, "-foo.bar", "", "malformed host name")
checkHost(t, "foo_.bar", "", "malformed host name")
checkHost(t, "_foo.bar", "", "malformed host name")
checkHost(t, "foo_baz.bar", "", "malformed host name")
checkHost(t, "foo__baz.bar", "", "malformed host name")
checkHost(t, "*.foo.bar", "", "malformed host name")
config = &Config{Features: []string{"relaxed-idna"}}
checkHost(t, "foo-.bar", "", "malformed host name")
checkHost(t, "-foo.bar", "", "malformed host name")
checkHost(t, "foo_.bar", "foo_.bar", "")
checkHost(t, "_foo.bar", "", "reserved host name")
checkHost(t, "foo_baz.bar", "foo_baz.bar", "")
checkHost(t, "foo__baz.bar", "foo__baz.bar", "")
checkHost(t, "*.foo.bar", "", "malformed host name")
}