Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions internal/plugins/proxy/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package proxy

import (
"encoding/json"
"maps"
"net/http"
"strconv"
"strings"
"unicode/utf8"
)

// proxyError is an error that is a constant, so that the sentinel errors below
// cannot be changed. Their messages are sentences that are shown to users as
// they are.
type proxyError string

// Error returns the message of the error.
func (e proxyError) Error() string {
return string(e)
}

// The errors that building a policy and starting the proxy can return. Use
// errors.Is to tell them apart.
const (
// ErrReloginRequired is what a TokenSource returns from Refresh when the
// session cannot be renewed without the user, which is the case for a user
// session. The plugin then gets a session_expired error that tells the user to
// log in again.
ErrReloginRequired = proxyError("Your Auth0 session cannot be renewed silently. Run `auth0 login` and try again.")

// ErrNoScopeTable means the registry index has no scope table, so there is no
// way to tell which Management API calls a scope allows. No plugin runs then.
ErrNoScopeTable = proxyError("The registry index has no scope table, so plugins cannot call the Management API.")

// ErrNoSecretFields means the registry index lists no secret fields. The
// proxy would forward responses without removing secrets, so no plugin runs.
ErrNoSecretFields = proxyError("The registry index lists no secret fields, so plugins cannot call the Management API.")

// ErrInvalidDomain means the tenant domain is not a bare host name.
ErrInvalidDomain = proxyError("The tenant domain must be a host name such as example.us.auth0.com, without a scheme, port or path.")
)

// errRefreshLimit is why the proxy stops asking the token source for a new
// token. The plugin never sees it, because it gets session_expired instead.
const errRefreshLimit = proxyError("The proxy refreshed the token too often in this run.")

// errorCode names why the proxy answered with an error. SDKs of the Management
// API read it from the errorCode field of the error body.
type errorCode string

// The error codes that the proxy answers with.
const (
codeInvalidProxyToken errorCode = "invalid_proxy_token"
codeBadRequest errorCode = "bad_request"
codeMethodNotAllowed errorCode = "method_not_allowed"
codeURITooLong errorCode = "uri_too_long"
codeForbiddenPath errorCode = "forbidden_path"
codeNoScopeRule errorCode = "no_scope_rule"
codeInsufficientScope errorCode = "insufficient_scope"
codePayloadTooLarge errorCode = "payload_too_large"
codeSessionExpired errorCode = "session_expired"
codeUnsupportedResponse errorCode = "unsupported_response"
codeUpstreamRedirect errorCode = "upstream_redirect"
codeUpstreamFailed errorCode = "upstream_failed"
)

// rejection is a request that the proxy answers itself instead of forwarding.
type rejection struct {
status int
code errorCode
message string

// The scope is the first one that would have allowed the call, when a missing
// scope is why it was denied. The summary names it.
scope string

// The header holds headers that go with the answer, such as Allow.
header http.Header
}

// reject returns a rejection with the given status and message.
func reject(status int, code errorCode, message string) *rejection {
return &rejection{status: status, code: code, message: message}
}

// write sends the rejection as the error body of the Management API.
func (r *rejection) write(w http.ResponseWriter) {
// Strings and integers always encode, so there is no error to handle.
body, _ := json.Marshal(struct {
StatusCode int `json:"statusCode"`
Error string `json:"error"`
Message string `json:"message"`
ErrorCode errorCode `json:"errorCode"`
}{r.status, http.StatusText(r.status), r.message, r.code})

maps.Copy(w.Header(), r.header)

w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.WriteHeader(r.status)
_, _ = w.Write(body)
}

// maxEchoBytes is the longest piece of a request that an error message repeats.
const maxEchoBytes = 80

// safeText returns s in a form that is safe to print to a terminal and to put in
// a message: printable ASCII only, with anything else replaced by a question
// mark, and cut to maxEchoBytes.
func safeText(s string) string {
var out strings.Builder

i := 0
for i < len(s) && out.Len() < maxEchoBytes {
r, size := utf8.DecodeRuneInString(s[i:])
i += size

if r >= ' ' && r <= '~' {
out.WriteRune(r)
} else {
out.WriteByte('?')
}
}

if i < len(s) {
out.WriteString("...")
}

return out.String()
}

// quoted returns s as a quoted, escaped and cut piece of text, for the errors
// that repeat a request that was not valid.
func quoted(s string) string {
if len(s) > maxEchoBytes {
s = strings.ToValidUTF8(s[:maxEchoBytes], "") + "..."
}

return strconv.QuoteToASCII(s)
}
62 changes: 62 additions & 0 deletions internal/plugins/proxy/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package proxy

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestRejection_write(t *testing.T) {
rej := reject(http.StatusForbidden, codeInsufficientScope, `Plugin a/b did not declare scope "read:users".`)
rej.header = http.Header{}
rej.header.Set("Allow", "GET")

recorder := httptest.NewRecorder()
rej.write(recorder)

assert.Equal(t, http.StatusForbidden, recorder.Code)
assert.Equal(t, "application/json", recorder.Header().Get("Content-Type"))
assert.Equal(t, "GET", recorder.Header().Get("Allow"))

var body map[string]any
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
assert.Equal(t, map[string]any{
"statusCode": float64(403),
"error": "Forbidden",
"message": `Plugin a/b did not declare scope "read:users".`,
"errorCode": "insufficient_scope",
}, body)
}

func TestSafeText(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "plain text", in: "GET /api/v2/users", want: "GET /api/v2/users"},
{name: "a control character", in: "a\x1b[31mb", want: "a?[31mb"},
{name: "a newline", in: "a\nb", want: "a?b"},
{name: "a bidi override", in: "a\u202eb", want: "a?b"},
{name: "invalid UTF-8", in: "a\xffb", want: "a?b"},
{name: "long text", in: strings.Repeat("a", 100), want: strings.Repeat("a", 80) + "..."},
{name: "text of exactly the limit", in: strings.Repeat("a", 80), want: strings.Repeat("a", 80)},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.want, safeText(test.in))
})
}
}

func TestQuoted(t *testing.T) {
assert.Equal(t, `"a\x1b[31m\u00e9"`, quoted("a\x1b[31mé"))
assert.Equal(t, `"`+strings.Repeat("a", 80)+`..."`, quoted(strings.Repeat("a", 100)))
assert.NotContains(t, quoted("\xff\xfe"), "\xff")
}
147 changes: 147 additions & 0 deletions internal/plugins/proxy/path.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package proxy

import (
"net/http"
"strconv"
"strings"
"unicode"
"unicode/utf8"
)

// The limits on what a plugin can send in the request line.
const (
maxPathBytes = 2048
maxQuery = 8 << 10
maxSegments = 18
)

// target is the part of a request that the proxy checked and forwards.
type target struct {
// The path is decoded. It is both what the scope check looks at and what is
// forwarded, so the two cannot differ.
path string

// The segments are those of the path after the Management API prefix.
segments []string

// The query is the raw query string, forwarded as it came.
query string
}

// parseTarget checks the request target of a request, which is the raw text of
// the request line, and returns what the proxy forwards. A path that is not
// already canonical is rejected, never repaired: the proxy does not clean up
// dot segments, repeated slashes or escapes, because a repair is a second
// interpretation of the path that could differ from the one the server behind
// the proxy makes.
func parseTarget(requestURI string) (target, *rejection) {
if !strings.HasPrefix(requestURI, "/") {
return target{}, reject(http.StatusBadRequest, codeBadRequest,
"The request target "+quoted(requestURI)+" must be a path that starts with /.")
}

rawPath, query, _ := strings.Cut(requestURI, "?")

if len(rawPath) > maxPathBytes || len(query) > maxQuery {
return target{}, reject(http.StatusRequestURITooLong, codeURITooLong, "The request target is too long.")
}

if !validQuery(query) {
return target{}, reject(http.StatusBadRequest, codeBadRequest, "The query string "+quoted(query)+" has characters that are not allowed.")
}

path, problem := decodePath(rawPath)
if problem != "" {
return target{}, reject(http.StatusBadRequest, codeBadRequest, "The path "+quoted(rawPath)+" is not allowed: "+problem+".")
}

segments := strings.Split(path[1:], "/")
if len(segments) > maxSegments {
return target{}, reject(http.StatusBadRequest, codeBadRequest, "The path "+quoted(rawPath)+" has too many segments.")
}

for _, segment := range segments {
if segment == "" || segment == "." || segment == ".." {
return target{}, reject(http.StatusBadRequest, codeBadRequest,
"The path "+quoted(rawPath)+" is not allowed: it has an empty or dot segment, or a trailing slash.")
}
}

if !strings.HasPrefix(path, managementPrefix) {
return target{}, reject(http.StatusForbidden, codeForbiddenPath,
"Plugins can only call the Management API, under "+managementPrefix+". The path "+quoted(safeText(path))+" is not allowed.")
}

// The prefix is the first two segments, and no segment is empty, so the rest
// of the segments are the ones after it.
return target{path: path, segments: segments[2:], query: query}, nil
}

// decodePath decodes the escapes of a raw path and returns the decoded path. It
// returns a short reason instead when the path is not canonical: an escape that
// hides a slash, a dot, a backslash, a null byte or a percent sign, an escape
// of a character that needs none, or a character that has no business in a
// path.
func decodePath(raw string) (string, string) {
var decoded strings.Builder

decoded.Grow(len(raw))

for i := 0; i < len(raw); i++ {
switch c := raw[i]; {
case c == '#':
return "", "it has a fragment"
case c != '%':
decoded.WriteByte(c)
case i+2 >= len(raw):
return "", "it has an escape that is cut short"
default:
value, err := strconv.ParseUint(raw[i+1:i+3], 16, 8)
if err != nil {
return "", "it has an escape that is not hexadecimal"
}

c = byte(value)
i += 2

switch {
case c == '/' || c == '.' || c == '\\' || c == 0 || c == '%':
return "", "it has an escaped slash, dot, backslash, null byte or percent sign"
case isUnreserved(c):
return "", "it escapes a character that needs no escape"
}

decoded.WriteByte(c)
}
}

path := decoded.String()

if !utf8.ValidString(path) {
return "", "it is not valid UTF-8"
}

if strings.ContainsAny(path, ";\\") || strings.IndexFunc(path, unicode.IsControl) >= 0 {
return "", "it has a semicolon, a backslash or a control character"
}

return path, ""
}

// isUnreserved reports whether c is a character that a URL never needs to
// escape: a letter, a digit, or one of - _ . ~.
func isUnreserved(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.' || c == '~'
}

// validQuery reports whether a raw query string only has printable ASCII
// characters and no fragment marker, so that it can be forwarded as it is.
func validQuery(query string) bool {
for i := 0; i < len(query); i++ {
if c := query[i]; c <= ' ' || c >= 0x7f || c == '#' {
return false
}
}

return true
}
Loading
Loading