refactor: rework scheme validation in oauth controller and frontend (#1026)

This commit is contained in:
Stavros
2026-07-19 00:41:28 +03:00
committed by GitHub
parent 50c25e4478
commit f43d690320
7 changed files with 114 additions and 165 deletions
+64 -53
View File
@@ -10,14 +10,13 @@ import (
"fmt"
"net"
"net/url"
"slices"
"strings"
"golang.org/x/net/idna"
)
// Errors
var (
ErrInvalidURL = fmt.Errorf("invalid url")
ErrSchemeMismatch = fmt.Errorf("scheme mismatch")
ErrPortMismatch = fmt.Errorf("port mismatch")
ErrHostnameMismatch = fmt.Errorf("hostname mismatch")
@@ -29,8 +28,7 @@ type DomainValidatorOptions struct {
WithScheme bool
// Ensure domains have the same port.
WithPort bool
// Specify a list of allowed schemes IF WithScheme is set to true.
// Leave empty to allow any scheme.
// Specify a list of allowed schemes if WithScheme is set to true.
AllowedSchemes []string
}
@@ -48,53 +46,74 @@ func NewDomainValidator(opts DomainValidatorOptions) *DomainValidator {
}
}
func (v *DomainValidator) checkScheme(rawURL string) error {
if !v.opts.WithScheme {
return nil
}
if len(v.opts.AllowedSchemes) == 0 {
return fmt.Errorf("allowed schemes must be specified")
}
for _, scheme := range v.opts.AllowedSchemes {
if strings.HasPrefix(strings.ToLower(rawURL), strings.ToLower(scheme)+"://") {
return nil
}
}
return fmt.Errorf("invalid scheme")
}
func (v *DomainValidator) getURL(i string) (*url.URL, error) {
u, err := url.Parse(i)
if !v.opts.WithScheme && (err != nil || u.Host == "") {
u, err = url.Parse("tinyauth://" + i)
}
if err != nil {
return nil, fmt.Errorf("failed to parse input url: %w", err)
}
if u.Host == "" {
return nil, ErrInvalidURL
}
if v.opts.WithPort && u.Port() == "" && (u.Scheme != "http" && u.Scheme != "https") {
return nil, fmt.Errorf("port validation is enabled but port is missing in input url and schemes are not enabled")
if i == "" {
return nil, fmt.Errorf("url cannot be empty")
}
if v.opts.WithScheme {
// Empty scheme means that we parsed the url with the tinyauth:// placeholder
if u.Scheme == "tinyauth" {
return nil, fmt.Errorf("input url is missing scheme")
err := v.checkScheme(i)
if err != nil {
return nil, fmt.Errorf("invalid scheme: %w", err)
}
if len(v.opts.AllowedSchemes) > 0 && !slices.Contains(v.opts.AllowedSchemes, u.Scheme) {
return nil, fmt.Errorf("scheme %s not allowed", u.Scheme)
u, err := url.Parse(i)
if err != nil {
return nil, fmt.Errorf("failed to parse input url: %w", err)
}
if u.Host == "" || u.Scheme == "" {
return nil, fmt.Errorf("missing host or scheme in url: %s", i)
}
return u, nil
}
rawURL := i
if !strings.Contains(i, "://") {
// From godoc: [scheme:][//[userinfo@]host][/]path[?query][#fragment]
// So, we can omit the colon and tell the Go URL lib that we want
// to parse the URL without the scheme. If we don't do this,
// the URL lib will parse our entire domain as the path.
rawURL = "//" + i
}
u, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("failed to parse host: %w", err)
}
if u.Host == "" {
return nil, fmt.Errorf("missing host in url: %s", i)
}
return u, nil
}
func (v *DomainValidator) getEffectivePort(u *url.URL) (string, bool) {
if u.Port() != "" {
return u.Port(), true
}
switch u.Scheme {
case "http":
return "80", true
case "https":
return "443", true
default:
return "", false
}
}
func (v *DomainValidator) formatHostname(hostname string) (string, error) {
func (v *DomainValidator) getHostname(hostname string) (string, error) {
hostname = strings.ToLower(hostname)
hostname = strings.TrimSuffix(hostname, ".")
if net.ParseIP(hostname) != nil {
@@ -133,26 +152,18 @@ func (v *DomainValidator) Validate(expected, actual string) error {
}
if v.opts.WithPort {
eup, ok := v.getEffectivePort(eu)
if !ok {
return fmt.Errorf("failed to get effective port for url: %s", eu.String())
}
aup, ok := v.getEffectivePort(au)
if !ok {
return fmt.Errorf("failed to get effective port for url: %s", au.String())
}
if eup != aup {
if eu.Port() != au.Port() {
return ErrPortMismatch
}
}
euf, err := v.formatHostname(eu.Hostname())
euf, err := v.getHostname(eu.Hostname())
if err != nil {
return err
}
auf, err := v.formatHostname(au.Hostname())
auf, err := v.getHostname(au.Hostname())
if err != nil {
return err
@@ -165,7 +176,7 @@ func (v *DomainValidator) Validate(expected, actual string) error {
return nil
}
// SafeHostname uses the internal validation for domains that Validator uses
// SafeHostname uses the internal validation for domains that the validator uses
// to parse a hostname. It ensures the input URL is a valid URL, that a host
// is present and that the hostname is lowercased and without a trailing dot.
func (v *DomainValidator) SafeHostname(input string) (string, error) {
@@ -175,5 +186,5 @@ func (v *DomainValidator) SafeHostname(input string) (string, error) {
return "", err
}
return v.formatHostname(u.Hostname())
return v.getHostname(u.Hostname())
}