2018-05-01 23:45:06 +00:00
|
|
|
package validation
|
|
|
|
|
|
|
|
import (
|
2019-11-12 18:50:41 +00:00
|
|
|
"context"
|
2018-05-01 23:45:06 +00:00
|
|
|
"fmt"
|
|
|
|
"net"
|
|
|
|
"net/url"
|
|
|
|
"strings"
|
2019-02-27 22:47:00 +00:00
|
|
|
"time"
|
2018-05-01 23:45:06 +00:00
|
|
|
|
2018-10-29 16:57:58 +00:00
|
|
|
"net/http"
|
|
|
|
|
2018-10-08 19:20:28 +00:00
|
|
|
"github.com/pkg/errors"
|
2018-05-01 23:45:06 +00:00
|
|
|
"golang.org/x/net/idna"
|
2019-11-12 18:50:41 +00:00
|
|
|
"gopkg.in/coreos/go-oidc.v2"
|
2018-05-01 23:45:06 +00:00
|
|
|
)
|
|
|
|
|
2019-11-12 18:50:41 +00:00
|
|
|
const (
|
|
|
|
defaultScheme = "http"
|
|
|
|
accessDomain = "cloudflareaccess.com"
|
|
|
|
accessCertPath = "/cdn-cgi/access/certs"
|
|
|
|
accessJwtHeader = "Cf-access-jwt-assertion"
|
|
|
|
)
|
2018-05-01 23:45:06 +00:00
|
|
|
|
2019-02-27 22:47:00 +00:00
|
|
|
var (
|
2020-05-13 18:53:31 +00:00
|
|
|
supportedProtocols = []string{"http", "https", "rdp", "ssh", "smb", "tcp"}
|
2019-11-12 18:50:41 +00:00
|
|
|
validationTimeout = time.Duration(30 * time.Second)
|
2019-02-27 22:47:00 +00:00
|
|
|
)
|
2018-05-01 23:45:06 +00:00
|
|
|
|
|
|
|
func ValidateHostname(hostname string) (string, error) {
|
|
|
|
if hostname == "" {
|
2018-10-08 19:20:28 +00:00
|
|
|
return "", nil
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
|
|
|
// users gives url(contains schema) not just hostname
|
|
|
|
if strings.Contains(hostname, ":") || strings.Contains(hostname, "%3A") {
|
|
|
|
unescapeHostname, err := url.PathUnescape(hostname)
|
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("Hostname(actually a URL) %s has invalid escape characters %s", hostname, unescapeHostname)
|
|
|
|
}
|
|
|
|
hostnameToURL, err := url.Parse(unescapeHostname)
|
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("Hostname(actually a URL) %s has invalid format %s", hostname, hostnameToURL)
|
|
|
|
}
|
|
|
|
asciiHostname, err := idna.ToASCII(hostnameToURL.Hostname())
|
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("Hostname(actually a URL) %s has invalid ASCII encdoing %s", hostname, asciiHostname)
|
|
|
|
}
|
|
|
|
return asciiHostname, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
asciiHostname, err := idna.ToASCII(hostname)
|
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("Hostname %s has invalid ASCII encdoing %s", hostname, asciiHostname)
|
|
|
|
}
|
|
|
|
hostnameToURL, err := url.Parse(asciiHostname)
|
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("Hostname %s is not valid", hostnameToURL)
|
|
|
|
}
|
|
|
|
return hostnameToURL.RequestURI(), nil
|
|
|
|
|
|
|
|
}
|
|
|
|
|
2019-11-13 15:11:35 +00:00
|
|
|
// ValidateUrl returns a validated version of `originUrl` with a scheme prepended (by default http://).
|
|
|
|
// Note: when originUrl contains a scheme, the path is removed:
|
|
|
|
// ValidateUrl("https://localhost:8080/api/") => "https://localhost:8080"
|
|
|
|
// but when it does not, the path is preserved:
|
|
|
|
// ValidateUrl("localhost:8080/api/") => "http://localhost:8080/api/"
|
|
|
|
// This is arguably a bug, but changing it might break some cloudflared users.
|
2020-10-08 10:12:26 +00:00
|
|
|
func ValidateUrl(originUrl string) (*url.URL, error) {
|
|
|
|
urlStr, err := validateUrlString(originUrl)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
return url.Parse(urlStr)
|
|
|
|
}
|
|
|
|
|
|
|
|
func validateUrlString(originUrl string) (string, error) {
|
2018-05-01 23:45:06 +00:00
|
|
|
if originUrl == "" {
|
2018-10-08 19:20:28 +00:00
|
|
|
return "", fmt.Errorf("URL should not be empty")
|
2018-05-01 23:45:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if net.ParseIP(originUrl) != nil {
|
|
|
|
return validateIP("", originUrl, "")
|
|
|
|
} else if strings.HasPrefix(originUrl, "[") && strings.HasSuffix(originUrl, "]") {
|
|
|
|
// ParseIP doesn't recoginze [::1]
|
|
|
|
return validateIP("", originUrl[1:len(originUrl)-1], "")
|
|
|
|
}
|
|
|
|
|
|
|
|
host, port, err := net.SplitHostPort(originUrl)
|
|
|
|
// user might pass in an ip address like 127.0.0.1
|
|
|
|
if err == nil && net.ParseIP(host) != nil {
|
|
|
|
return validateIP("", host, port)
|
|
|
|
}
|
|
|
|
|
|
|
|
unescapedUrl, err := url.PathUnescape(originUrl)
|
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("URL %s has invalid escape characters %s", originUrl, unescapedUrl)
|
|
|
|
}
|
|
|
|
|
|
|
|
parsedUrl, err := url.Parse(unescapedUrl)
|
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("URL %s has invalid format", originUrl)
|
|
|
|
}
|
|
|
|
|
|
|
|
// if the url is in the form of host:port, IsAbs() will think host is the schema
|
|
|
|
var hostname string
|
|
|
|
hasScheme := parsedUrl.IsAbs() && parsedUrl.Host != ""
|
|
|
|
if hasScheme {
|
|
|
|
err := validateScheme(parsedUrl.Scheme)
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
// The earlier check for ip address will miss the case http://[::1]
|
|
|
|
// and http://[::1]:8080
|
|
|
|
if net.ParseIP(parsedUrl.Hostname()) != nil {
|
|
|
|
return validateIP(parsedUrl.Scheme, parsedUrl.Hostname(), parsedUrl.Port())
|
|
|
|
}
|
|
|
|
hostname, err = ValidateHostname(parsedUrl.Hostname())
|
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("URL %s has invalid format", originUrl)
|
|
|
|
}
|
|
|
|
if parsedUrl.Port() != "" {
|
|
|
|
return fmt.Sprintf("%s://%s", parsedUrl.Scheme, net.JoinHostPort(hostname, parsedUrl.Port())), nil
|
|
|
|
}
|
|
|
|
return fmt.Sprintf("%s://%s", parsedUrl.Scheme, hostname), nil
|
|
|
|
} else {
|
|
|
|
if host == "" {
|
|
|
|
hostname, err = ValidateHostname(originUrl)
|
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("URL no %s has invalid format", originUrl)
|
|
|
|
}
|
|
|
|
return fmt.Sprintf("%s://%s", defaultScheme, hostname), nil
|
|
|
|
} else {
|
|
|
|
hostname, err = ValidateHostname(host)
|
|
|
|
if err != nil {
|
|
|
|
return "", fmt.Errorf("URL %s has invalid format", originUrl)
|
|
|
|
}
|
2019-11-13 15:11:35 +00:00
|
|
|
// This is why the path is preserved when `originUrl` doesn't have a schema.
|
|
|
|
// Using `parsedUrl.Port()` here, instead of `port`, would remove the path
|
2018-05-01 23:45:06 +00:00
|
|
|
return fmt.Sprintf("%s://%s", defaultScheme, net.JoinHostPort(hostname, port)), nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
func validateScheme(scheme string) error {
|
2019-03-10 04:23:30 +00:00
|
|
|
for _, protocol := range supportedProtocols {
|
2018-05-01 23:45:06 +00:00
|
|
|
if scheme == protocol {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return fmt.Errorf("Currently Argo Tunnel does not support %s protocol.", scheme)
|
|
|
|
}
|
|
|
|
|
|
|
|
func validateIP(scheme, host, port string) (string, error) {
|
|
|
|
if scheme == "" {
|
|
|
|
scheme = defaultScheme
|
|
|
|
}
|
|
|
|
if port != "" {
|
|
|
|
return fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(host, port)), nil
|
|
|
|
} else if strings.Contains(host, ":") {
|
|
|
|
// IPv6
|
|
|
|
return fmt.Sprintf("%s://[%s]", scheme, host), nil
|
|
|
|
}
|
|
|
|
return fmt.Sprintf("%s://%s", scheme, host), nil
|
|
|
|
}
|
2018-10-08 19:20:28 +00:00
|
|
|
|
2020-10-08 10:12:26 +00:00
|
|
|
// originURL shouldn't be a pointer, because this function might change the scheme
|
2020-11-02 11:21:34 +00:00
|
|
|
func ValidateHTTPService(originURL string, hostname string, transport http.RoundTripper) error {
|
|
|
|
parsedURL, err := url.Parse(originURL)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2018-12-07 17:35:05 +00:00
|
|
|
client := &http.Client{
|
|
|
|
Transport: transport,
|
|
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
|
|
return http.ErrUseLastResponse
|
|
|
|
},
|
2019-02-27 22:47:00 +00:00
|
|
|
Timeout: validationTimeout,
|
2018-12-07 17:35:05 +00:00
|
|
|
}
|
2018-10-08 19:20:28 +00:00
|
|
|
|
2020-11-02 11:21:34 +00:00
|
|
|
initialRequest, err := http.NewRequest("GET", parsedURL.String(), nil)
|
2018-10-29 16:57:58 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
initialRequest.Host = hostname
|
2020-09-11 22:02:34 +00:00
|
|
|
resp, initialErr := client.Do(initialRequest)
|
|
|
|
if initialErr == nil {
|
|
|
|
resp.Body.Close()
|
|
|
|
return nil
|
|
|
|
}
|
2018-10-08 19:20:28 +00:00
|
|
|
|
2020-09-11 22:02:34 +00:00
|
|
|
// Attempt the same endpoint via the other protocol (http/https); maybe we have better luck?
|
2020-11-02 11:21:34 +00:00
|
|
|
oldScheme := parsedURL.Scheme
|
|
|
|
parsedURL.Scheme = toggleProtocol(oldScheme)
|
2020-09-11 22:02:34 +00:00
|
|
|
|
2020-11-02 11:21:34 +00:00
|
|
|
secondRequest, err := http.NewRequest("GET", parsedURL.String(), nil)
|
2020-09-11 22:02:34 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
secondRequest.Host = hostname
|
|
|
|
resp, secondErr := client.Do(secondRequest)
|
|
|
|
if secondErr == nil { // Worked this time--advise the user to switch protocols
|
2020-11-25 06:55:13 +00:00
|
|
|
_ = resp.Body.Close()
|
2020-09-11 22:02:34 +00:00
|
|
|
return errors.Errorf(
|
2020-10-08 10:12:26 +00:00
|
|
|
"%s doesn't seem to work over %s, but does seem to work over %s. Reason: %v. Consider changing the origin URL to %v",
|
2020-11-02 11:21:34 +00:00
|
|
|
parsedURL.Host,
|
2020-09-11 22:02:34 +00:00
|
|
|
oldScheme,
|
2020-11-02 11:21:34 +00:00
|
|
|
parsedURL.Scheme,
|
2020-09-11 22:02:34 +00:00
|
|
|
initialErr,
|
2020-10-08 10:12:26 +00:00
|
|
|
originURL,
|
2020-09-11 22:02:34 +00:00
|
|
|
)
|
2018-10-08 19:20:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return initialErr
|
|
|
|
}
|
|
|
|
|
|
|
|
func toggleProtocol(httpProtocol string) string {
|
|
|
|
switch httpProtocol {
|
|
|
|
case "http":
|
|
|
|
return "https"
|
|
|
|
case "https":
|
|
|
|
return "http"
|
|
|
|
default:
|
|
|
|
return httpProtocol
|
|
|
|
}
|
|
|
|
}
|
2019-11-12 18:50:41 +00:00
|
|
|
|
|
|
|
// Access checks if a JWT from Cloudflare Access is valid.
|
|
|
|
type Access struct {
|
|
|
|
verifier *oidc.IDTokenVerifier
|
|
|
|
}
|
|
|
|
|
|
|
|
func NewAccessValidator(ctx context.Context, domain, issuer, applicationAUD string) (*Access, error) {
|
2020-10-08 10:12:26 +00:00
|
|
|
domainURL, err := validateUrlString(domain)
|
2019-11-12 18:50:41 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2020-10-08 10:12:26 +00:00
|
|
|
issuerURL, err := validateUrlString(issuer)
|
2019-11-12 18:50:41 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// An issuerURL from Cloudflare Access will always use HTTPS.
|
|
|
|
issuerURL = strings.Replace(issuerURL, "http:", "https:", 1)
|
|
|
|
|
|
|
|
keySet := oidc.NewRemoteKeySet(ctx, domainURL+accessCertPath)
|
|
|
|
return &Access{oidc.NewVerifier(issuerURL, keySet, &oidc.Config{ClientID: applicationAUD})}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (a *Access) Validate(ctx context.Context, jwt string) error {
|
|
|
|
token, err := a.verifier.Verify(ctx, jwt)
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return errors.Wrapf(err, "token is invalid: %s", jwt)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Perform extra sanity checks, just to be safe.
|
|
|
|
|
|
|
|
if token == nil {
|
|
|
|
return fmt.Errorf("token is nil: %s", jwt)
|
|
|
|
}
|
|
|
|
|
|
|
|
if !strings.HasSuffix(token.Issuer, accessDomain) {
|
|
|
|
return fmt.Errorf("token has non-cloudflare issuer of %s: %s", token.Issuer, jwt)
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (a *Access) ValidateRequest(ctx context.Context, r *http.Request) error {
|
|
|
|
return a.Validate(ctx, r.Header.Get(accessJwtHeader))
|
|
|
|
}
|