mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-28 23:39:07 +01:00
Apply target access before service authentication: inherit, bypass, or block. Keep route selection and auth decisions on the same snapshot; reject stale or unavailable required middleware. Validation: full auth/proxy race tests and scoped lint passed. Runtime regression tests remain with this layer.
301 lines
8.6 KiB
Go
301 lines
8.6 KiB
Go
package proxy
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
|
"github.com/netbirdio/netbird/proxy/internal/middleware/bodytap"
|
|
)
|
|
|
|
// ErrUnsafeRequestPath indicates that a request path cannot be matched safely.
|
|
var ErrUnsafeRequestPath = errors.New("unsafe request path")
|
|
|
|
// TargetResolver is an immutable routing snapshot shared by auth and forwarding.
|
|
type TargetResolver struct {
|
|
mapping Mapping
|
|
strictPaths bool
|
|
hasBypass bool
|
|
}
|
|
|
|
type targetResolution struct {
|
|
result targetResult
|
|
matched bool
|
|
}
|
|
|
|
type targetResolutionContextKey struct{}
|
|
|
|
// NewTargetResolver builds an immutable target-routing snapshot.
|
|
func NewTargetResolver(mapping Mapping) (*TargetResolver, error) {
|
|
snapshot, err := cloneResolverMapping(mapping)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resolver := &TargetResolver{mapping: snapshot}
|
|
for _, target := range snapshot.Paths {
|
|
action := effectiveAccessAction(target.AccessAction)
|
|
if action != AccessActionInherit {
|
|
resolver.strictPaths = true
|
|
}
|
|
if action == AccessActionBypass {
|
|
resolver.hasBypass = true
|
|
}
|
|
}
|
|
return resolver, nil
|
|
}
|
|
|
|
// ResolveRequest selects and pins a target for both auth and forwarding.
|
|
func (r *TargetResolver) ResolveRequest(req *http.Request) (*http.Request, AccessAction, error) {
|
|
if r == nil {
|
|
return req, AccessActionInherit, nil
|
|
}
|
|
if req == nil || req.URL == nil {
|
|
return req, AccessActionInherit, fmt.Errorf("%w: missing URL", ErrUnsafeRequestPath)
|
|
}
|
|
if r.strictPaths {
|
|
if err := validateAccessPath(req.URL); err != nil {
|
|
return req, AccessActionInherit, err
|
|
}
|
|
}
|
|
|
|
result, matched := findTargetInMapping(req.URL.Path, r.mapping)
|
|
resolution := targetResolution{result: result, matched: matched}
|
|
ctx := context.WithValue(req.Context(), targetResolutionContextKey{}, resolution)
|
|
resolvedReq := req.WithContext(ctx)
|
|
if !matched {
|
|
return resolvedReq, AccessActionInherit, nil
|
|
}
|
|
|
|
if cd := CapturedDataFromContext(ctx); cd != nil {
|
|
cd.SetServiceID(result.serviceID)
|
|
cd.SetAccountID(result.accountID)
|
|
cd.SetAgentNetwork(result.target.AgentNetwork)
|
|
cd.SetSuppressAccessLog(result.target.DisableAccessLog)
|
|
}
|
|
return resolvedReq, effectiveAccessAction(result.target.AccessAction), nil
|
|
}
|
|
|
|
// HasBypass reports whether the snapshot contains an authentication bypass.
|
|
func (r *TargetResolver) HasBypass() bool {
|
|
return r != nil && r.hasBypass
|
|
}
|
|
|
|
// WithMiddlewareRevision returns a routing snapshot bound to the given policy revision.
|
|
func (r *TargetResolver) WithMiddlewareRevision(revision middleware.Revision) *TargetResolver {
|
|
bound := *r
|
|
bound.mapping.MiddlewareRevision = revision
|
|
return &bound
|
|
}
|
|
|
|
func targetResolutionFromContext(ctx context.Context) (targetResolution, bool) {
|
|
resolution, ok := ctx.Value(targetResolutionContextKey{}).(targetResolution)
|
|
return resolution, ok
|
|
}
|
|
|
|
func effectiveAccessAction(action AccessAction) AccessAction {
|
|
if action == "" {
|
|
return AccessActionInherit
|
|
}
|
|
return action
|
|
}
|
|
|
|
func mappingHasNonDefaultAccessAction(mapping Mapping) bool {
|
|
for _, target := range mapping.Paths {
|
|
if target != nil && effectiveAccessAction(target.AccessAction) != AccessActionInherit {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func cloneResolverMapping(mapping Mapping) (Mapping, error) {
|
|
snapshot := mapping
|
|
snapshot.Paths = make(map[string]*PathTarget, len(mapping.Paths))
|
|
snapshot.StripAuthHeaders = append([]string(nil), mapping.StripAuthHeaders...)
|
|
snapshot.sortedPaths = make([]string, 0, len(mapping.Paths))
|
|
|
|
for path, target := range mapping.Paths {
|
|
if target == nil || target.URL == nil {
|
|
return Mapping{}, fmt.Errorf("target for path %q is incomplete", path)
|
|
}
|
|
action := effectiveAccessAction(target.AccessAction)
|
|
switch action {
|
|
case AccessActionInherit, AccessActionBypass, AccessActionBlock:
|
|
default:
|
|
return Mapping{}, fmt.Errorf("unknown access action %q for path %q", target.AccessAction, path)
|
|
}
|
|
if action == AccessActionBypass && target.AgentNetwork {
|
|
return Mapping{}, fmt.Errorf("authentication bypass is not allowed for Agent Network target %q", path)
|
|
}
|
|
if action != AccessActionInherit {
|
|
if err := validateAccessPrefix(path); err != nil {
|
|
return Mapping{}, fmt.Errorf("invalid access-controlled path %q: %w", path, err)
|
|
}
|
|
}
|
|
|
|
cloned := clonePathTarget(target)
|
|
cloned.AccessAction = action
|
|
snapshot.Paths[path] = cloned
|
|
snapshot.sortedPaths = append(snapshot.sortedPaths, path)
|
|
}
|
|
sort.Slice(snapshot.sortedPaths, func(i, j int) bool {
|
|
return len(snapshot.sortedPaths[i]) > len(snapshot.sortedPaths[j])
|
|
})
|
|
snapshot.requirePinnedResolution = mappingHasNonDefaultAccessAction(snapshot)
|
|
return snapshot, nil
|
|
}
|
|
|
|
func validateAccessPrefix(prefix string) error {
|
|
if strings.ContainsAny(prefix, "?#") {
|
|
return fmt.Errorf("%w: query or fragment delimiter", ErrUnsafeRequestPath)
|
|
}
|
|
return validateAccessPath(&url.URL{Path: prefix})
|
|
}
|
|
|
|
func clonePathTarget(target *PathTarget) *PathTarget {
|
|
cloned := *target
|
|
clonedURL := *target.URL
|
|
cloned.URL = &clonedURL
|
|
cloned.CustomHeaders = maps.Clone(target.CustomHeaders)
|
|
cloned.Middlewares = cloneMiddlewareSpecs(target.Middlewares)
|
|
cloned.CaptureConfig = cloneCaptureConfig(target.CaptureConfig)
|
|
return &cloned
|
|
}
|
|
|
|
func cloneMiddlewareSpecs(specs []middleware.Spec) []middleware.Spec {
|
|
if specs == nil {
|
|
return nil
|
|
}
|
|
cloned := make([]middleware.Spec, len(specs))
|
|
for i, spec := range specs {
|
|
cloned[i] = spec.Clone()
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func cloneCaptureConfig(config *bodytap.Config) *bodytap.Config {
|
|
if config == nil {
|
|
return nil
|
|
}
|
|
cloned := *config
|
|
cloned.ContentTypes = append([]string(nil), config.ContentTypes...)
|
|
return &cloned
|
|
}
|
|
|
|
func findTargetInMapping(path string, mapping Mapping) (targetResult, bool) {
|
|
for _, prefix := range mapping.sortedPaths {
|
|
if !strings.HasPrefix(path, prefix) {
|
|
continue
|
|
}
|
|
target := mapping.Paths[prefix]
|
|
if target == nil || target.URL == nil {
|
|
continue
|
|
}
|
|
return targetResult{
|
|
target: target,
|
|
matchedPath: prefix,
|
|
serviceID: mapping.ID,
|
|
accountID: mapping.AccountID,
|
|
passHostHeader: mapping.PassHostHeader,
|
|
rewriteRedirects: mapping.RewriteRedirects,
|
|
stripAuthHeaders: mapping.StripAuthHeaders,
|
|
middlewareRevision: mapping.MiddlewareRevision,
|
|
requirePinned: mapping.requirePinnedResolution,
|
|
}, true
|
|
}
|
|
return targetResult{requirePinned: mapping.requirePinnedResolution}, false
|
|
}
|
|
|
|
func validateAccessPath(requestURL *url.URL) error {
|
|
if requestURL.Opaque != "" || requestURL.Path == "" || requestURL.Path[0] != '/' {
|
|
return fmt.Errorf("%w: path must be absolute", ErrUnsafeRequestPath)
|
|
}
|
|
if !utf8.ValidString(requestURL.Path) {
|
|
return fmt.Errorf("%w: path is not valid UTF-8", ErrUnsafeRequestPath)
|
|
}
|
|
if requestURL.RawPath != "" {
|
|
decoded, err := url.PathUnescape(requestURL.RawPath)
|
|
if err != nil || decoded != requestURL.Path {
|
|
return fmt.Errorf("%w: invalid escaped path", ErrUnsafeRequestPath)
|
|
}
|
|
}
|
|
if err := validateDecodedAccessPath(requestURL.Path); err != nil {
|
|
return err
|
|
}
|
|
return validateEscapedAccessPath(requestURL.EscapedPath())
|
|
}
|
|
|
|
func validateDecodedAccessPath(path string) error {
|
|
if strings.Contains(path, "//") || strings.ContainsAny(path, "\\;?#") {
|
|
return fmt.Errorf("%w: ambiguous path separator", ErrUnsafeRequestPath)
|
|
}
|
|
for _, segment := range strings.Split(path, "/") {
|
|
if segment == "." || segment == ".." {
|
|
return fmt.Errorf("%w: dot segment", ErrUnsafeRequestPath)
|
|
}
|
|
}
|
|
for _, char := range path {
|
|
if char < 0x20 || char == 0x7f {
|
|
return fmt.Errorf("%w: control character", ErrUnsafeRequestPath)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateEscapedAccessPath(escapedPath string) error {
|
|
for i := 0; i < len(escapedPath); i++ {
|
|
if escapedPath[i] != '%' {
|
|
continue
|
|
}
|
|
if i+2 >= len(escapedPath) {
|
|
return fmt.Errorf("%w: truncated escape", ErrUnsafeRequestPath)
|
|
}
|
|
value, ok := decodeHexByte(escapedPath[i+1], escapedPath[i+2])
|
|
if !ok {
|
|
return fmt.Errorf("%w: invalid escape", ErrUnsafeRequestPath)
|
|
}
|
|
switch value {
|
|
case '/', '\\', '.', '%', 0:
|
|
return fmt.Errorf("%w: ambiguous escaped character", ErrUnsafeRequestPath)
|
|
}
|
|
if value < 0x20 || value == 0x7f {
|
|
return fmt.Errorf("%w: escaped control character", ErrUnsafeRequestPath)
|
|
}
|
|
i += 2
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func decodeHexByte(high, low byte) (byte, bool) {
|
|
hi, ok := hexNibble(high)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
lo, ok := hexNibble(low)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
return hi<<4 | lo, true
|
|
}
|
|
|
|
func hexNibble(value byte) (byte, bool) {
|
|
switch {
|
|
case value >= '0' && value <= '9':
|
|
return value - '0', true
|
|
case value >= 'a' && value <= 'f':
|
|
return value - 'a' + 10, true
|
|
case value >= 'A' && value <= 'F':
|
|
return value - 'A' + 10, true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|