Skip to content
Closed
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
12 changes: 9 additions & 3 deletions agent/server/grpctunnel/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,11 @@ type conformanceCase struct {
URL string `json:"url"`
Headers map[string]string `json:"headers"`
// Code is the CallCancel status a rejected request must carry.
// Defaults to 404 (no rule matched); 400 says the request was
// malformed before matching — bad encoding, directory traversal.
// Defaults to 404 (no rule matched); 403 says a rule matched but did
// not authorize the destination the request named.
Code int32 `json:"code"`
// AbsentHeaders names headers that must not reach the upstream.
AbsentHeaders []string `json:"absentHeaders"`
} `json:"expect"`
}

Expand Down Expand Up @@ -93,7 +95,8 @@ func runConformanceFixture(t *testing.T, path string) {
rules = append(rules, r)
}
}
router := NewRouter(rules, zap.NewNop())
router, err := NewRouter(rules, zap.NewNop())
require.NoError(t, err)

for _, c := range fixture.Cases {
t.Run(c.Name, func(t *testing.T) {
Expand Down Expand Up @@ -127,6 +130,9 @@ func runConformanceFixture(t *testing.T, path string) {
for k, v := range c.Expect.Headers {
assert.Equal(t, v, breq.Header.Get(k), "outgoing header %q", k)
}
for _, k := range c.Expect.AbsentHeaders {
assert.Empty(t, breq.Header.Values(k), "header %q must not reach the upstream", k)
}
})
}
}
15 changes: 12 additions & 3 deletions agent/server/grpctunnel/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,15 @@ type Router struct {
inner *acceptfile.Router
}

// NewRouter creates a Router over rendered accept file rules.
func NewRouter(rules []acceptfile.AcceptFileRuleWrapper, logger *zap.Logger) *Router {
return &Router{inner: acceptfile.NewRouter(rules, logger)}
// NewRouter creates a Router over rendered accept file rules. It fails when a
// rule's origin cannot be resolved into a policy — a malformed wildcard, an
// undialable port — so the agent stops at startup rather than per request.
func NewRouter(rules []acceptfile.AcceptFileRuleWrapper, logger *zap.Logger) (*Router, error) {
inner, err := acceptfile.NewRouter(rules, logger)
if err != nil {
return nil, err
}
return &Router{inner: inner}, nil
}

// Route resolves a CallStart to a BackendRequest, or a *RouteError with an
Expand All @@ -47,6 +53,9 @@ func (rt *Router) Route(start *pb.CallStart) (*BackendRequest, error) {
return nil, &RouteError{Code: http.StatusNotFound, Reason: err.Error()}
case errors.As(err, &invalid):
return nil, &RouteError{Code: http.StatusBadRequest, Reason: invalid.Reason}
case errors.Is(err, acceptfile.ErrDestinationRejected):
// The reason names the policy, never the value that failed it.
return nil, &RouteError{Code: http.StatusForbidden, Reason: err.Error()}
default:
return nil, &RouteError{Code: http.StatusBadGateway, Reason: err.Error()}
}
Expand Down
25 changes: 17 additions & 8 deletions agent/server/grpctunnel/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ func makeRouterRules(t *testing.T, rules string) []acceptfile.AcceptFileRuleWrap
return filtered
}

// newRouter builds a Router over the rules, failing the test if the accept
// file declares a policy the Router refuses.
func newRouter(t *testing.T, rulesJSON string) *Router {
t.Helper()
router, err := NewRouter(makeRouterRules(t, rulesJSON), zap.NewNop())
require.NoError(t, err)
return router
}

func callStart(method, path string, headers map[string]string) *pb.CallStart {
return &pb.CallStart{
PseudoHeaders: map[string]string{":method": method, ":path": path},
Expand Down Expand Up @@ -84,7 +93,7 @@ func TestRouterBackend_BasicRequest(t *testing.T) {
]
}`, server.URL)

router := NewRouter(makeRouterRules(t, rulesJSON), zap.NewNop())
router := newRouter(t, rulesJSON)
status, body, headers := doCall(t, router, callStart("GET", "/api/v1/repos", nil), nil)
assert.Equal(t, http.StatusOK, status)
assert.Equal(t, `{"repos": []}`, body)
Expand All @@ -103,7 +112,7 @@ func TestRouterBackend_QueryStringForwarded(t *testing.T) {
"private": [{"method": "GET", "path": "/api/*", "origin": "%s"}]
}`, server.URL)

router := NewRouter(makeRouterRules(t, rulesJSON), zap.NewNop())
router := newRouter(t, rulesJSON)
status, _, _ := doCall(t, router, callStart("GET", "/api/search?q=foo&page=2", nil), nil)
assert.Equal(t, http.StatusOK, status)
}
Expand All @@ -120,7 +129,7 @@ func TestRouterBackend_EncodedSlashPreserved(t *testing.T) {
"private": [{"method": "any", "path": "/api/*", "origin": "%s"}]
}`, server.URL)

router := NewRouter(makeRouterRules(t, rulesJSON), zap.NewNop())
router := newRouter(t, rulesJSON)
status, _, _ := doCall(t, router, callStart("GET", "/api/v4/projects/group%2Fproject", nil), nil)
assert.Equal(t, http.StatusOK, status)
}
Expand All @@ -136,7 +145,7 @@ func TestRouter_NoMatchingRule(t *testing.T) {
]
}`

router := NewRouter(makeRouterRules(t, rulesJSON), zap.NewNop())
router := newRouter(t, rulesJSON)
_, err := router.Route(callStart("GET", "/unknown/path", nil))
require.Error(t, err)
var re *RouteError
Expand Down Expand Up @@ -167,7 +176,7 @@ func TestRouterBackend_BearerAuth(t *testing.T) {
]
}`, server.URL)

router := NewRouter(makeRouterRules(t, rulesJSON), zap.NewNop())
router := newRouter(t, rulesJSON)
status, _, _ := doCall(t, router, callStart("GET", "/api/repos", nil), nil)
assert.Equal(t, http.StatusOK, status)
}
Expand Down Expand Up @@ -197,7 +206,7 @@ func TestRouterBackend_BasicAuth(t *testing.T) {
]
}`, server.URL)

router := NewRouter(makeRouterRules(t, rulesJSON), zap.NewNop())
router := newRouter(t, rulesJSON)
status, _, _ := doCall(t, router, callStart("POST", "/api/data", nil), strings.NewReader(`{"key":"value"}`))
assert.Equal(t, http.StatusOK, status)
}
Expand All @@ -222,7 +231,7 @@ func TestRouterBackend_RuleHeaderInjection(t *testing.T) {
]
}`, server.URL)

router := NewRouter(makeRouterRules(t, rulesJSON), zap.NewNop())
router := newRouter(t, rulesJSON)
status, _, _ := doCall(t, router, callStart("GET", "/api/repos", map[string]string{"x-custom": "caller-value"}), nil)
assert.Equal(t, http.StatusOK, status)
}
Expand Down Expand Up @@ -250,7 +259,7 @@ func TestRouterBackend_StreamedRequestBody(t *testing.T) {
bodyW.Close()
}()

router := NewRouter(makeRouterRules(t, rulesJSON), zap.NewNop())
router := newRouter(t, rulesJSON)
status, _, _ := doCall(t, router, callStart("POST", "/api/upload", nil), bodyR)
assert.Equal(t, http.StatusOK, status)
}
6 changes: 5 additions & 1 deletion agent/server/grpctunnel/tunnel_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,11 @@ func (tc *tunnelClient) setupRouter() error {
if err != nil {
return fmt.Errorf("error parsing rendered accept file: %w", err)
}
tc.router = NewRouter(af2.Wrapper().PrivateRules(), tc.logger)
router, err := NewRouter(af2.Wrapper().PrivateRules(), tc.logger)
if err != nil {
return fmt.Errorf("error building accept file router: %w", err)
}
tc.router = router
return nil
}

Expand Down
4 changes: 3 additions & 1 deletion agent/server/grpctunnel/tunnel_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@ func catchAllRouter(t *testing.T) *Router {
"private": [{"method": "any", "path": "/*", "origin": "http://stub.internal"}]
}`), cfg, zap.NewNop())
require.NoError(t, err)
return NewRouter(af.Wrapper().PrivateRules(), zap.NewNop())
router, err := NewRouter(af.Wrapper().PrivateRules(), zap.NewNop())
require.NoError(t, err)
return router
}

// -----------------------------------------------------------------------------
Expand Down
23 changes: 16 additions & 7 deletions agent/server/snykbroker/acceptfile/accept_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package acceptfile
import (
"encoding/json"
"fmt"
"net/url"
"os"
"sync"

Expand Down Expand Up @@ -234,21 +233,22 @@ func (r AcceptFileRuleWrapper) Origin() string {
return ""
}
origin := os.ExpandEnv(rawOrigin)
asUrl, err := url.Parse(origin)
withScheme, err := defaultScheme(origin)
if err != nil {
r.acceptFile.logger.Panic("failed to parse origin URL", zap.String("origin", rawOrigin), zap.Error(err))
}
if asUrl.Scheme == "" {
if withScheme != origin {
if _, seen := r.acceptFile.schemeWarned.LoadOrStore(rawOrigin, struct{}{}); !seen {
r.acceptFile.logger.Debug("origin URL has no scheme, defaulting to https", zap.String("origin", rawOrigin))
}
asUrl.Scheme = "https"
return asUrl.String()
}
return origin

return withScheme
}

// Path returns the rule's path pattern as written, ${VAR} references included.
// Expanding them here would pin the segment to the configured value; matchPath
// instead treats ${VAR} as snyk-broker does, matching any one segment and
// substituting the configured value into the outgoing URL.
func (r AcceptFileRuleWrapper) Path() string {
path, ok := r.dict["path"].(string)
if !ok {
Expand All @@ -257,6 +257,15 @@ func (r AcceptFileRuleWrapper) Path() string {
return path
}

// RawOrigin returns the rule's origin exactly as written, with ${VAR}
// references intact. The Router needs this because pool rotation has to happen
// on the reference: expanding ${API} against the environment first turns a
// pool-only variable into the empty string, leaving nothing to rotate.
func (r AcceptFileRuleWrapper) RawOrigin() string {
origin, _ := r.dict["origin"].(string)
return origin
}

func (r AcceptFileRuleWrapper) SetOrigin(origin string) {
r.dict["origin"] = origin
}
Expand Down
98 changes: 98 additions & 0 deletions agent/server/snykbroker/acceptfile/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package acceptfile

import (
"encoding/base64"
"net/http"
"os"
"sort"
"strings"
)

// authScheme is an accept-file auth scheme, always lowercased. Go has no enum,
// but a named type keeps the scheme from being just another string in flight
// and lets the declaration below and its uses refer to the same symbols.
type authScheme string

const (
authSchemeBearer authScheme = "bearer"
authSchemeToken authScheme = "token"
authSchemeBasic authScheme = "basic"
authSchemeRaw authScheme = "raw"
)

// authHeaderBuilders is the single declaration of which schemes Axon supports:
// a scheme is supported exactly when there is a builder for it here.
//
// The list and the behaviour have to be one table, not two. They were a set in
// supported.go and a switch in router.go, which could drift in either
// direction — a scheme in the set with no case sends no credential while
// claiming to be supported, and a case with no set entry warns about itself
// while working fine.
//
// The bodies match snyk-broker's lib/common/utils/auth-header.ts.
var authHeaderBuilders = map[authScheme]func(auth *AcceptFileRuleAuth) string{
authSchemeBearer: func(auth *AcceptFileRuleAuth) string {
return "Bearer " + os.ExpandEnv(auth.Token)
},
authSchemeToken: func(auth *AcceptFileRuleAuth) string {
return "Token " + os.ExpandEnv(auth.Token)
},
// The upstream's header carries no scheme prefix at all.
authSchemeRaw: func(auth *AcceptFileRuleAuth) string {
return os.ExpandEnv(auth.Token)
},
authSchemeBasic: func(auth *AcceptFileRuleAuth) string {
return "Basic " + basicCredential(auth)
},
}

// authHeaderBuilder returns how to build the header for a scheme as written in
// the accept file, and whether the scheme is one Axon supports at all.
func authHeaderBuilder(scheme string) (func(*AcceptFileRuleAuth) string, bool) {
build, ok := authHeaderBuilders[authScheme(strings.ToLower(scheme))]
return build, ok
}

// isSupportedAuthScheme reports whether the Router can build a credential for
// the scheme as the accept file spells it.
func isSupportedAuthScheme(scheme string) bool {
_, ok := authHeaderBuilder(scheme)
return ok
}

// supportedAuthSchemes lists the schemes in a stable order, for a message that
// has to name them.
func supportedAuthSchemes() []string {
names := make([]string, 0, len(authHeaderBuilders))
for scheme := range authHeaderBuilders {
names = append(names, string(scheme))
}
sort.Strings(names)
return names
}

// applyAuth sets the Authorization header from the rule's auth block.
//
// An unrecognized scheme sets no header at all, which is what snyk-broker's
// authHeader() does with one; warnUnsupportedRule has already said so when the
// Router was built.
func applyAuth(header http.Header, auth *AcceptFileRuleAuth) {
if auth == nil {
return
}
if build, ok := authHeaderBuilder(auth.Scheme); ok {
header.Set("Authorization", build(auth))
}
}

// basicCredential builds the base64 payload. A basic block carrying "token"
// holds the already-encoded user:pass pair (the shape Azure Repos uses), so
// encoding it again would send a broken credential.
func basicCredential(auth *AcceptFileRuleAuth) string {
if auth.Username != "" || auth.Password != "" {
username := os.ExpandEnv(auth.Username)
password := os.ExpandEnv(auth.Password)
return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
}
return os.ExpandEnv(auth.Token)
}
58 changes: 58 additions & 0 deletions agent/server/snykbroker/acceptfile/headers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package acceptfile

import "strings"

// Request-header helpers shared by rule matching and routing. None of this is
// origin policy or match semantics; it is just the handful of things that have
// to be true when headers arrive as a map with whatever casing the caller used.

// getHeaderCaseInsensitive retrieves a header value with case-insensitive key
// matching.
func getHeaderCaseInsensitive(headers map[string]string, key string) (string, bool) {
if headers == nil {
return "", false
}

// Try exact match first.
if v, ok := headers[key]; ok {
return v, true
}

keyLower := strings.ToLower(key)
for k, v := range headers {
if strings.ToLower(k) == keyLower {
return v, true
}
}

return "", false
}

// isWebSocketUpgrade reports whether the request headers ask for an upgrade.
func isWebSocketUpgrade(headers map[string]string) bool {
upgrade, ok := getHeaderCaseInsensitive(headers, "Upgrade")
if !ok {
return false
}
return strings.EqualFold(strings.TrimSpace(upgrade), "websocket")
}

// takeTargetHosts removes every spelling of the target-host header, returning
// the values it carried and the headers without it. Callers get a copy, so the
// caller's own map is left alone.
//
// Every spelling, and all of them: a map can hold the header twice under
// different casing, and two values is a shape the destination policy refuses
// rather than picks from.
func takeTargetHosts(headers map[string]string) ([]string, map[string]string) {
var values []string
remaining := make(map[string]string, len(headers))
for k, v := range headers {
if strings.EqualFold(k, HeaderTargetHost) {
values = append(values, v)
continue
}
remaining[k] = v
}
return values, remaining
}
Loading
Loading