Accept only an origin written exactly as a browser sends it (closes #61)

access_control_allow_origin is now "*", or http or https, a host that is
an IP address as net/netip writes it (IPv6 in brackets) or a lowercase
host name whose last part contains a letter, and an optional port 1-65535
with no leading zero that is not the scheme's default. The value must
equal the origin rebuilt from those parts; anything else aborts startup
naming the key, its variable and the value. README.md and
config.example.yml say an origin is scheme, host and optional port,
exactly as the browser sends it.

Model: opus-5-5
This commit is contained in:
2026-09-29 06:12:11 +00:00
parent 2e06be40c9
commit e836e88b8f
4 changed files with 43 additions and 41 deletions
+35 -35
View File
@@ -624,13 +624,9 @@ func (c *Config) validateUpstreamMaxResponseSize() error {
return nil
}
// validateAccessControlAllowOrigin checks that access_control_allow_origin
// is "*" or one origin as browsers send it in the Origin header: a scheme,
// a host name or IP address, and optionally a port from 1 to 65535, with
// nothing after them. Anything else, such as a bare hostname or a trailing
// slash, would match no request. A "*" is not allowed in a host name, so
// https://*example.com is refused; the CORS middleware would read that
// "*" as a pattern and let other sites read responses.
// validateAccessControlAllowOrigin accepts "*" or an origin exactly as a browser
// sends it: http or https, an IP address as netip writes it or a lowercase name
// with a letter in its last part, and an optional port 1-65535, not the default.
func (c *Config) validateAccessControlAllowOrigin() error {
origin := c.AccessControlAllowOrigin
if origin == "*" {
@@ -640,50 +636,54 @@ func (c *Config) validateAccessControlAllowOrigin() error {
errOrigin := fmt.Errorf("%s: value %q is %w",
settingName(keyAccessControlAllowOrigin), origin, errNotAnOrigin)
// url.Parse accepts an empty port, as in https://example.com:, and
// then reports no port, so a trailing colon is refused here.
parsed, err := url.Parse(origin)
if err != nil || parsed.Scheme+"://"+parsed.Host != origin ||
strings.HasSuffix(origin, ":") {
if err != nil {
return errOrigin
}
defaultPort := map[string]string{"http": "80", "https": "443"}[parsed.Scheme]
if defaultPort == "" {
return errOrigin
}
const letters = "abcdefghijklmnopqrstuvwxyz"
host := parsed.Hostname()
lastPart := host[strings.LastIndex(host, ".")+1:]
_, err = netip.ParseAddr(host)
if err != nil && !isHostName(host) {
addr, err := netip.ParseAddr(host)
switch {
case err == nil && addr.Is6():
host = "[" + addr.String() + "]"
case err == nil:
host = addr.String()
case strings.Trim(host, letters+"0123456789-.") != "": // a character other than these
return errOrigin
case !strings.ContainsAny(lastPart, letters):
return errOrigin
}
// A port is a 16-bit number, and 0 is not a port a browser sends.
if parsed.Port() != "" {
port, err := strconv.ParseUint(parsed.Port(), 10, 16)
if err != nil || port == 0 {
// The value must be exactly the origin rebuilt from its parts.
rebuilt := parsed.Scheme + "://" + host
port := parsed.Port()
if port != "" {
_, err := strconv.ParseUint(port, 10, 16)
if err != nil || port[0] == '0' || port == defaultPort {
return errOrigin
}
rebuilt += ":" + port
}
if rebuilt != origin {
return errOrigin
}
return nil
}
// isHostName reports whether host is not empty and has only ASCII
// letters, digits, hyphens and dots.
func isHostName(host string) bool {
if host == "" {
return false
}
for _, r := range host {
isLetterOrDigit := 'a' <= r && r <= 'z' || 'A' <= r && r <= 'Z' ||
'0' <= r && r <= '9'
if !isLetterOrDigit && r != '-' && r != '.' {
return false
}
}
return true
}
// validateAllowlistHost checks that an allowlist_hosts entry is a bare
// hostname, optionally with a leading dot for suffix matching. URLs,
// paths, and whitespace indicate a misconfigured entry. An entry with