Compare commits

..

3 Commits

Author SHA1 Message Date
Maycon Santos
001fda5393 Add Linux ARM64 UI build 2026-07-27 18:13:44 +02:00
Misha Bragin
1816a020c4 [management, proxy] Add Claude Opus 5 (#6895) 2026-07-27 16:15:36 +02:00
Zoltan Papp
aa13928b76 [client] Export agent version info for iOS (#6918)
## Describe your changes

Export agent version info for iOS

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [ ] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [x] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [ ] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).

> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/netbirdio/codesmith/netbird/pr/6918"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1787750541&installation_model_id=427504&pr_number=6918&repository=netbirdio%2Fnetbird&return_to=https%3A%2F%2Fgithub.com%2Fnetbirdio%2Fnetbird%2Fpull%2F6918&signature=c2de74d89c36b01aac5866422b0be0b7525f7c6e26e46174efc280339eb864b6"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->
2026-07-27 15:53:09 +02:00
43 changed files with 799 additions and 3363 deletions

View File

@@ -21,6 +21,7 @@ builds:
- linux
goarch:
- amd64
- arm64
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"

View File

@@ -0,0 +1,12 @@
//go:build ios
package NetBirdSDK
import "github.com/netbirdio/netbird/version"
// GoClientVersion returns the NetBird Go client version that was baked into
// the framework at compile time via
// -ldflags "-X github.com/netbirdio/netbird/version.version=<version>".
func GoClientVersion() string {
return version.NetbirdVersion()
}

View File

@@ -228,8 +228,6 @@ read_enable_crowdsec() {
echo "CrowdSec checks client IPs against a community threat intelligence database" > /dev/stderr
echo "and blocks known malicious sources before they reach your services." > /dev/stderr
echo "A local CrowdSec LAPI container will be added to your deployment." > /dev/stderr
echo "It also enables the AppSec (WAF) endpoint, so services can inspect HTTP" > /dev/stderr
echo "requests for exploits. Both stay off per service until you enable them." > /dev/stderr
echo -n "Enable CrowdSec? [y/N]: " > /dev/stderr
read -r CHOICE < /dev/tty
@@ -499,8 +497,7 @@ generate_configuration_files() {
# TCP ServersTransport for PROXY protocol v2 to the proxy backend
render_traefik_dynamic > traefik-dynamic.yaml
if [[ "$ENABLE_CROWDSEC" == "true" ]]; then
mkdir -p crowdsec/acquis.d
render_crowdsec_appsec_acquis > crowdsec/acquis.d/appsec.yaml
mkdir -p crowdsec
fi
fi
;;
@@ -534,23 +531,6 @@ generate_configuration_files() {
return 0
}
# The AppSec (WAF) listener only exists if an appsec acquisition datasource is
# configured. One datasource is one listener carrying one merged rule set: the
# protocol has no rule-set selector, so per-service rule variation would need
# either a second datasource on another port or pre_eval hooks filtering on
# req.Host.
render_crowdsec_appsec_acquis() {
cat <<EOF
source: appsec
listen_addr: 0.0.0.0:7422
appsec_configs:
- crowdsecurity/appsec-default
labels:
type: appsec
EOF
return 0
}
start_services_and_show_instructions() {
# For built-in Traefik, start containers immediately
# For NPM, start containers first (NPM needs services running to create proxy)
@@ -762,11 +742,7 @@ render_docker_compose_traefik_builtin() {
restart: unless-stopped
networks: [netbird]
environment:
# appsec-generic-rules is required alongside appsec-virtual-patching:
# the appsec-default config references crowdsecurity/generic-* and
# crowdsecurity/experimental-*, which only that collection provides, and
# the engine exits at startup if they are missing.
COLLECTIONS: crowdsecurity/linux crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
COLLECTIONS: crowdsecurity/linux
volumes:
- ./crowdsec:/etc/crowdsec
- crowdsec_db:/var/lib/crowdsec/data
@@ -1031,11 +1007,6 @@ EOF
cat <<EOF
NB_PROXY_CROWDSEC_API_URL=http://crowdsec:8080
NB_PROXY_CROWDSEC_API_KEY=$CROWDSEC_BOUNCER_KEY
# AppSec (WAF) request inspection. Separate endpoint from the LAPI above and
# validated with the same bouncer key. Setting it makes the proxy advertise the
# AppSec capability, which is what lets a service select appsec_mode; nothing is
# inspected until a service opts in.
NB_PROXY_CROWDSEC_APPSEC_URL=http://crowdsec:7422/
EOF
fi

View File

@@ -23,9 +23,6 @@ type Domain struct {
// SupportsCrowdSec is populated at query time from proxy cluster capabilities.
// Not persisted.
SupportsCrowdSec *bool `gorm:"-"`
// SupportsAppSec is populated at query time from proxy cluster capabilities.
// Not persisted.
SupportsAppSec *bool `gorm:"-"`
// SupportsPrivate is populated at query time from proxy cluster capabilities. Not persisted.
SupportsPrivate *bool `gorm:"-"`
}

View File

@@ -49,7 +49,6 @@ func domainToApi(d *domain.Domain) api.ReverseProxyDomain {
SupportsCustomPorts: d.SupportsCustomPorts,
RequireSubdomain: d.RequireSubdomain,
SupportsCrowdsec: d.SupportsCrowdSec,
SupportsAppsec: d.SupportsAppSec,
SupportsPrivate: d.SupportsPrivate,
}
if d.TargetCluster != "" {

View File

@@ -35,7 +35,6 @@ type proxyManager interface {
ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
}
@@ -95,7 +94,6 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
d.SupportsCustomPorts = m.proxyManager.ClusterSupportsCustomPorts(ctx, cluster)
d.RequireSubdomain = m.proxyManager.ClusterRequireSubdomain(ctx, cluster)
d.SupportsCrowdSec = m.proxyManager.ClusterSupportsCrowdSec(ctx, cluster)
d.SupportsAppSec = m.proxyManager.ClusterSupportsAppSec(ctx, cluster)
d.SupportsPrivate = m.proxyManager.ClusterSupportsPrivate(ctx, cluster)
ret = append(ret, d)
}
@@ -113,7 +111,6 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
if d.TargetCluster != "" {
cd.SupportsCustomPorts = m.proxyManager.ClusterSupportsCustomPorts(ctx, d.TargetCluster)
cd.SupportsCrowdSec = m.proxyManager.ClusterSupportsCrowdSec(ctx, d.TargetCluster)
cd.SupportsAppSec = m.proxyManager.ClusterSupportsAppSec(ctx, d.TargetCluster)
cd.SupportsPrivate = m.proxyManager.ClusterSupportsPrivate(ctx, d.TargetCluster)
}
// Custom domains never require a subdomain by default since

View File

@@ -40,10 +40,6 @@ func (m *mockProxyManager) ClusterSupportsCrowdSec(_ context.Context, _ string)
return nil
}
func (m *mockProxyManager) ClusterSupportsAppSec(_ context.Context, _ string) *bool {
return nil
}
func (m *mockProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool {
return nil
}

View File

@@ -19,7 +19,6 @@ type Manager interface {
ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
CleanupStale(ctx context.Context, inactivityDuration time.Duration) error
GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error)

View File

@@ -21,7 +21,6 @@ type store interface {
GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error
GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error)
@@ -139,13 +138,6 @@ func (m Manager) ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string
return m.store.GetClusterSupportsCrowdSec(ctx, clusterAddr)
}
// ClusterSupportsAppSec returns whether all active proxies in the cluster have
// a CrowdSec AppSec endpoint configured (unanimous). Returns nil when no proxy
// has reported capabilities.
func (m Manager) ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool {
return m.store.GetClusterSupportsAppSec(ctx, clusterAddr)
}
// ClusterSupportsPrivate reports whether any active proxy claims the private capability (nil = unreported).
func (m Manager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool {
return m.store.GetClusterSupportsPrivate(ctx, clusterAddr)

View File

@@ -99,9 +99,6 @@ func (m *mockStore) GetClusterRequireSubdomain(_ context.Context, _ string) *boo
func (m *mockStore) GetClusterSupportsCrowdSec(_ context.Context, _ string) *bool {
return nil
}
func (m *mockStore) GetClusterSupportsAppSec(_ context.Context, _ string) *bool {
return nil
}
func (m *mockStore) GetClusterSupportsPrivate(_ context.Context, _ string) *bool {
return nil
}

View File

@@ -50,6 +50,20 @@ func (mr *MockManagerMockRecorder) CleanupStale(ctx, inactivityDuration interfac
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupStale", reflect.TypeOf((*MockManager)(nil).CleanupStale), ctx, inactivityDuration)
}
// ClusterSupportsCustomPorts mocks base method.
func (m *MockManager) ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ClusterSupportsCustomPorts", ctx, clusterAddr)
ret0, _ := ret[0].(*bool)
return ret0
}
// ClusterSupportsCustomPorts indicates an expected call of ClusterSupportsCustomPorts.
func (mr *MockManagerMockRecorder) ClusterSupportsCustomPorts(ctx, clusterAddr interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCustomPorts", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCustomPorts), ctx, clusterAddr)
}
// ClusterRequireSubdomain mocks base method.
func (m *MockManager) ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
@@ -64,20 +78,6 @@ func (mr *MockManagerMockRecorder) ClusterRequireSubdomain(ctx, clusterAddr inte
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterRequireSubdomain", reflect.TypeOf((*MockManager)(nil).ClusterRequireSubdomain), ctx, clusterAddr)
}
// ClusterSupportsAppSec mocks base method.
func (m *MockManager) ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ClusterSupportsAppSec", ctx, clusterAddr)
ret0, _ := ret[0].(*bool)
return ret0
}
// ClusterSupportsAppSec indicates an expected call of ClusterSupportsAppSec.
func (mr *MockManagerMockRecorder) ClusterSupportsAppSec(ctx, clusterAddr interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsAppSec", reflect.TypeOf((*MockManager)(nil).ClusterSupportsAppSec), ctx, clusterAddr)
}
// ClusterSupportsCrowdSec mocks base method.
func (m *MockManager) ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
@@ -92,20 +92,6 @@ func (mr *MockManagerMockRecorder) ClusterSupportsCrowdSec(ctx, clusterAddr inte
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCrowdSec", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCrowdSec), ctx, clusterAddr)
}
// ClusterSupportsCustomPorts mocks base method.
func (m *MockManager) ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ClusterSupportsCustomPorts", ctx, clusterAddr)
ret0, _ := ret[0].(*bool)
return ret0
}
// ClusterSupportsCustomPorts indicates an expected call of ClusterSupportsCustomPorts.
func (mr *MockManagerMockRecorder) ClusterSupportsCustomPorts(ctx, clusterAddr interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCustomPorts", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCustomPorts), ctx, clusterAddr)
}
// ClusterSupportsPrivate mocks base method.
func (m *MockManager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
@@ -135,35 +121,6 @@ func (mr *MockManagerMockRecorder) Connect(ctx, proxyID, sessionID, clusterAddre
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockManager)(nil).Connect), ctx, proxyID, sessionID, clusterAddress, ipAddress, accountID, capabilities)
}
// CountAccountProxies mocks base method.
func (m *MockManager) CountAccountProxies(ctx context.Context, accountID string) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CountAccountProxies", ctx, accountID)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CountAccountProxies indicates an expected call of CountAccountProxies.
func (mr *MockManagerMockRecorder) CountAccountProxies(ctx, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAccountProxies", reflect.TypeOf((*MockManager)(nil).CountAccountProxies), ctx, accountID)
}
// DeleteAccountCluster mocks base method.
func (m *MockManager) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteAccountCluster", ctx, clusterAddress, accountID)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteAccountCluster indicates an expected call of DeleteAccountCluster.
func (mr *MockManagerMockRecorder) DeleteAccountCluster(ctx, clusterAddress, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockManager)(nil).DeleteAccountCluster), ctx, clusterAddress, accountID)
}
// Disconnect mocks base method.
func (m *MockManager) Disconnect(ctx context.Context, proxyID, sessionID string) error {
m.ctrl.T.Helper()
@@ -178,21 +135,6 @@ func (mr *MockManagerMockRecorder) Disconnect(ctx, proxyID, sessionID interface{
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Disconnect", reflect.TypeOf((*MockManager)(nil).Disconnect), ctx, proxyID, sessionID)
}
// GetAccountProxy mocks base method.
func (m *MockManager) GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAccountProxy", ctx, accountID)
ret0, _ := ret[0].(*Proxy)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetAccountProxy indicates an expected call of GetAccountProxy.
func (mr *MockManagerMockRecorder) GetAccountProxy(ctx, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountProxy", reflect.TypeOf((*MockManager)(nil).GetAccountProxy), ctx, accountID)
}
// GetActiveClusterAddresses mocks base method.
func (m *MockManager) GetActiveClusterAddresses(ctx context.Context) ([]string, error) {
m.ctrl.T.Helper()
@@ -208,7 +150,6 @@ func (mr *MockManagerMockRecorder) GetActiveClusterAddresses(ctx interface{}) *g
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveClusterAddresses", reflect.TypeOf((*MockManager)(nil).GetActiveClusterAddresses), ctx)
}
// GetActiveClusterAddressesForAccount mocks base method.
func (m *MockManager) GetActiveClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetActiveClusterAddressesForAccount", ctx, accountID)
@@ -217,7 +158,6 @@ func (m *MockManager) GetActiveClusterAddressesForAccount(ctx context.Context, a
return ret0, ret1
}
// GetActiveClusterAddressesForAccount indicates an expected call of GetActiveClusterAddressesForAccount.
func (mr *MockManagerMockRecorder) GetActiveClusterAddressesForAccount(ctx, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveClusterAddressesForAccount", reflect.TypeOf((*MockManager)(nil).GetActiveClusterAddressesForAccount), ctx, accountID)
@@ -237,6 +177,36 @@ func (mr *MockManagerMockRecorder) Heartbeat(ctx, p interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Heartbeat", reflect.TypeOf((*MockManager)(nil).Heartbeat), ctx, p)
}
// GetAccountProxy mocks base method.
func (m *MockManager) GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAccountProxy", ctx, accountID)
ret0, _ := ret[0].(*Proxy)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetAccountProxy indicates an expected call of GetAccountProxy.
func (mr *MockManagerMockRecorder) GetAccountProxy(ctx, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountProxy", reflect.TypeOf((*MockManager)(nil).GetAccountProxy), ctx, accountID)
}
// CountAccountProxies mocks base method.
func (m *MockManager) CountAccountProxies(ctx context.Context, accountID string) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CountAccountProxies", ctx, accountID)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CountAccountProxies indicates an expected call of CountAccountProxies.
func (mr *MockManagerMockRecorder) CountAccountProxies(ctx, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAccountProxies", reflect.TypeOf((*MockManager)(nil).CountAccountProxies), ctx, accountID)
}
// IsClusterAddressAvailable mocks base method.
func (m *MockManager) IsClusterAddressAvailable(ctx context.Context, clusterAddress, accountID string) (bool, error) {
m.ctrl.T.Helper()
@@ -252,6 +222,20 @@ func (mr *MockManagerMockRecorder) IsClusterAddressAvailable(ctx, clusterAddress
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsClusterAddressAvailable", reflect.TypeOf((*MockManager)(nil).IsClusterAddressAvailable), ctx, clusterAddress, accountID)
}
// DeleteAccountCluster mocks base method.
func (m *MockManager) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteAccountCluster", ctx, clusterAddress, accountID)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteAccountCluster indicates an expected call of DeleteAccountCluster.
func (mr *MockManagerMockRecorder) DeleteAccountCluster(ctx, clusterAddress, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockManager)(nil).DeleteAccountCluster), ctx, clusterAddress, accountID)
}
// MockController is a mock of Controller interface.
type MockController struct {
ctrl *gomock.Controller

View File

@@ -20,9 +20,6 @@ type Capabilities struct {
RequireSubdomain *bool
// SupportsCrowdsec indicates whether this proxy has CrowdSec configured.
SupportsCrowdsec *bool
// SupportsAppsec indicates whether this proxy has a CrowdSec AppSec (WAF)
// endpoint configured.
SupportsAppsec *bool
// Private indicates whether this proxy supports inbound access via Wireguard
// tunnel and netbird-only authentication policies
Private *bool
@@ -77,6 +74,5 @@ type Cluster struct {
SupportsCustomPorts *bool
RequireSubdomain *bool
SupportsCrowdSec *bool
SupportsAppSec *bool
Private *bool
}

View File

@@ -204,7 +204,6 @@ func (h *handler) getClusters(w http.ResponseWriter, r *http.Request) {
SupportsCustomPorts: c.SupportsCustomPorts,
RequireSubdomain: c.RequireSubdomain,
SupportsCrowdsec: c.SupportsCrowdSec,
SupportsAppsec: c.SupportsAppSec,
Private: c.Private,
})
}

View File

@@ -82,7 +82,6 @@ type CapabilityProvider interface {
ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
}
@@ -138,7 +137,6 @@ func (m *Manager) GetClusters(ctx context.Context, accountID, userID string) ([]
clusters[i].SupportsCustomPorts = m.capabilities.ClusterSupportsCustomPorts(ctx, clusters[i].Address)
clusters[i].RequireSubdomain = m.capabilities.ClusterRequireSubdomain(ctx, clusters[i].Address)
clusters[i].SupportsCrowdSec = m.capabilities.ClusterSupportsCrowdSec(ctx, clusters[i].Address)
clusters[i].SupportsAppSec = m.capabilities.ClusterSupportsAppSec(ctx, clusters[i].Address)
clusters[i].Private = m.capabilities.ClusterSupportsPrivate(ctx, clusters[i].Address)
}

View File

@@ -165,18 +165,6 @@ type AccessRestrictions struct {
AllowedCountries []string `json:"allowed_countries,omitempty" gorm:"serializer:json"`
BlockedCountries []string `json:"blocked_countries,omitempty" gorm:"serializer:json"`
CrowdSecMode string `json:"crowdsec_mode,omitempty" gorm:"serializer:json"`
// AppSecMode is the CrowdSec AppSec (WAF) request inspection mode: "",
// "off", "enforce", or "observe". HTTP services only.
AppSecMode string `json:"appsec_mode,omitempty" gorm:"serializer:json"`
}
// isEmpty reports whether no restriction is configured. Both conversions drop
// the object entirely in that case, so a field missing from this check is
// silently discarded on the way to the API and the proxy.
func (r AccessRestrictions) isEmpty() bool {
return len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 &&
len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 &&
r.CrowdSecMode == "" && r.AppSecMode == ""
}
// Copy returns a deep copy of the AccessRestrictions.
@@ -187,7 +175,6 @@ func (r AccessRestrictions) Copy() AccessRestrictions {
AllowedCountries: slices.Clone(r.AllowedCountries),
BlockedCountries: slices.Clone(r.BlockedCountries),
CrowdSecMode: r.CrowdSecMode,
AppSecMode: r.AppSecMode,
}
}
@@ -821,17 +808,13 @@ func restrictionsFromAPI(r *api.AccessRestrictions) (AccessRestrictions, error)
}
res.CrowdSecMode = string(*r.CrowdsecMode)
}
if r.AppsecMode != nil {
if !r.AppsecMode.Valid() {
return AccessRestrictions{}, fmt.Errorf("invalid appsec_mode %q", *r.AppsecMode)
}
res.AppSecMode = string(*r.AppsecMode)
}
return res, nil
}
func restrictionsToAPI(r AccessRestrictions) *api.AccessRestrictions {
if r.isEmpty() {
if len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 &&
len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 &&
r.CrowdSecMode == "" {
return nil
}
res := &api.AccessRestrictions{}
@@ -851,15 +834,13 @@ func restrictionsToAPI(r AccessRestrictions) *api.AccessRestrictions {
mode := api.AccessRestrictionsCrowdsecMode(r.CrowdSecMode)
res.CrowdsecMode = &mode
}
if r.AppSecMode != "" {
mode := api.AccessRestrictionsAppsecMode(r.AppSecMode)
res.AppsecMode = &mode
}
return res
}
func restrictionsToProto(r AccessRestrictions) *proto.AccessRestrictions {
if r.isEmpty() {
if len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 &&
len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 &&
r.CrowdSecMode == "" {
return nil
}
return &proto.AccessRestrictions{
@@ -868,7 +849,6 @@ func restrictionsToProto(r AccessRestrictions) *proto.AccessRestrictions {
AllowedCountries: r.AllowedCountries,
BlockedCountries: r.BlockedCountries,
CrowdsecMode: r.CrowdSecMode,
AppsecMode: r.AppSecMode,
}
}
@@ -894,11 +874,6 @@ func (s *Service) Validate() error {
if err := validateAccessRestrictions(&s.Restrictions); err != nil {
return err
}
// AppSec inspects HTTP requests, so it cannot apply to the L4 modes, which
// forward opaque byte streams.
if appSecEnabled(s.Restrictions.AppSecMode) && s.Mode != ModeHTTP {
return fmt.Errorf("appsec_mode is only supported for HTTP services, got mode %q", s.Mode)
}
if err := s.validatePrivateRequirements(); err != nil {
return err
}
@@ -1267,27 +1242,10 @@ func validateCrowdSecMode(mode string) error {
}
}
func validateAppSecMode(mode string) error {
switch mode {
case "", "off", "enforce", "observe":
return nil
default:
return fmt.Errorf("appsec_mode %q is invalid", mode)
}
}
// appSecEnabled reports whether the mode asks for request inspection.
func appSecEnabled(mode string) bool {
return mode == "enforce" || mode == "observe"
}
func validateAccessRestrictions(r *AccessRestrictions) error {
if err := validateCrowdSecMode(r.CrowdSecMode); err != nil {
return err
}
if err := validateAppSecMode(r.AppSecMode); err != nil {
return err
}
if len(r.AllowedCIDRs) > maxCIDREntries {
return fmt.Errorf("allowed_cidrs: exceeds maximum of %d entries", maxCIDREntries)

View File

@@ -26,17 +26,6 @@ func validProxy() *Service {
}
}
// validL4Proxy returns a service that passes validation in one of the L4 modes.
func validL4Proxy(mode string) *Service {
rp := validProxy()
rp.Mode = mode
rp.ListenPort = 9000
rp.Targets = []*Target{
{TargetId: "peer-1", TargetType: TargetTypePeer, Host: "10.0.0.1", Port: 5432, Protocol: mode, Enabled: true},
}
return rp
}
func TestValidate_Valid(t *testing.T) {
require.NoError(t, validProxy().Validate())
}
@@ -1326,68 +1315,3 @@ func TestValidate_Private_RejectsNonHTTPMode(t *testing.T) {
}}
assert.ErrorContains(t, rp.Validate(), "HTTP")
}
func TestRestrictions_AppSecMode_RoundTrip(t *testing.T) {
mode := api.AccessRestrictionsAppsecModeEnforce
apiIn := &api.AccessRestrictions{AppsecMode: &mode}
model, err := restrictionsFromAPI(apiIn)
require.NoError(t, err)
assert.Equal(t, "enforce", model.AppSecMode)
// appsec_mode alone must keep the restrictions object alive on both the API
// and proto legs: it is meaningful without any CIDR or country entry.
apiOut := restrictionsToAPI(model)
require.NotNil(t, apiOut, "appsec_mode alone must not collapse the restrictions to nil")
require.NotNil(t, apiOut.AppsecMode)
assert.Equal(t, api.AccessRestrictionsAppsecModeEnforce, *apiOut.AppsecMode)
protoOut := restrictionsToProto(model)
require.NotNil(t, protoOut, "appsec_mode alone must reach the proxy")
assert.Equal(t, "enforce", protoOut.AppsecMode)
}
func TestRestrictions_AppSecMode_EmptyIsOmitted(t *testing.T) {
model, err := restrictionsFromAPI(&api.AccessRestrictions{
AllowedCidrs: &[]string{"203.0.113.0/24"},
})
require.NoError(t, err)
assert.Empty(t, model.AppSecMode)
apiOut := restrictionsToAPI(model)
require.NotNil(t, apiOut)
assert.Nil(t, apiOut.AppsecMode, "empty appsec_mode is omitted from the API response")
}
func TestRestrictions_AppSecMode_CopyIsDeep(t *testing.T) {
original := AccessRestrictions{AppSecMode: "observe", CrowdSecMode: "enforce"}
assert.Equal(t, original, original.Copy(), "Copy must carry every mode field")
}
func TestValidate_RejectsInvalidAppSecMode(t *testing.T) {
rp := validProxy()
rp.Restrictions = AccessRestrictions{AppSecMode: "sometimes"}
assert.ErrorContains(t, rp.Validate(), "appsec_mode")
}
func TestValidate_RejectsAppSecOnL4Modes(t *testing.T) {
// AppSec inspects HTTP requests, so the L4 modes cannot honor it. Accepting
// the field there would report protection that never runs.
for _, mode := range []string{ModeTCP, ModeUDP, ModeTLS} {
t.Run(mode, func(t *testing.T) {
rp := validL4Proxy(mode)
rp.Restrictions = AccessRestrictions{AppSecMode: "enforce"}
assert.ErrorContains(t, rp.Validate(), "appsec_mode is only supported for HTTP services")
})
}
}
func TestValidate_AllowsAppSecOffOnL4Modes(t *testing.T) {
for _, mode := range []string{ModeTCP, ModeUDP, ModeTLS} {
t.Run(mode, func(t *testing.T) {
rp := validL4Proxy(mode)
rp.Restrictions = AccessRestrictions{AppSecMode: "off"}
require.NoError(t, rp.Validate(), "an explicit off must not be rejected on L4 services")
})
}
}

View File

@@ -29,7 +29,6 @@ import (
"github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/internals/modules/peers"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
@@ -37,6 +36,7 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/management/server/users"
proxyauth "github.com/netbirdio/netbird/proxy/auth"
@@ -505,7 +505,6 @@ func (s *ProxyServiceServer) registerProxyConnection(ctx context.Context, params
SupportsCustomPorts: c.SupportsCustomPorts,
RequireSubdomain: c.RequireSubdomain,
SupportsCrowdsec: c.SupportsCrowdsec,
SupportsAppsec: c.SupportsAppsec,
Private: c.Private,
}
}

View File

@@ -2259,7 +2259,7 @@ func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*p
}
func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpservice.Service, error) {
const serviceQuery = `SELECT id, account_id, name, domain, enabled, auth, restrictions,
const serviceQuery = `SELECT id, account_id, name, domain, enabled, auth,
meta_created_at, meta_certificate_issued_at, meta_status, proxy_cluster,
pass_host_header, rewrite_redirects, session_private_key, session_public_key,
mode, listen_port, port_auto_assigned, source, source_peer, terminated,
@@ -2278,7 +2278,6 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
services, err := pgx.CollectRows(serviceRows, func(row pgx.CollectableRow) (*rpservice.Service, error) {
var s rpservice.Service
var auth []byte
var restrictions []byte
var accessGroups []byte
var createdAt, certIssuedAt sql.NullTime
var status, proxyCluster, sessionPrivateKey, sessionPublicKey sql.NullString
@@ -2292,7 +2291,6 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
&s.Domain,
&s.Enabled,
&auth,
&restrictions,
&createdAt,
&certIssuedAt,
&status,
@@ -2320,12 +2318,6 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
}
}
if len(restrictions) > 0 {
if err := json.Unmarshal(restrictions, &s.Restrictions); err != nil {
return nil, fmt.Errorf("unmarshal restrictions: %w", err)
}
}
if len(accessGroups) > 0 {
if err := json.Unmarshal(accessGroups, &s.AccessGroups); err != nil {
return nil, fmt.Errorf("unmarshal access_groups: %w", err)
@@ -6365,7 +6357,6 @@ var validCapabilityColumns = map[string]struct{}{
"supports_custom_ports": {},
"require_subdomain": {},
"supports_crowdsec": {},
"supports_appsec": {},
"private": {},
}
@@ -6396,14 +6387,6 @@ func (s *SqlStore) GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr s
return s.getClusterUnanimousCapability(ctx, clusterAddr, "supports_crowdsec")
}
// GetClusterSupportsAppSec returns whether all active proxies in the cluster
// have a CrowdSec AppSec endpoint configured. Returns nil when no proxy
// reported the capability. Unanimous for the same reason as CrowdSec: a single
// proxy without AppSec would let requests through uninspected.
func (s *SqlStore) GetClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool {
return s.getClusterUnanimousCapability(ctx, clusterAddr, "supports_appsec")
}
// getClusterUnanimousCapability returns an aggregated boolean capability
// requiring all active proxies in the cluster to report true.
func (s *SqlStore) getClusterUnanimousCapability(ctx context.Context, clusterAddr, column string) *bool {

View File

@@ -44,42 +44,3 @@ func TestSqlStore_GetAccount_PrivateServiceRoundtrip(t *testing.T) {
assert.Equal(t, []string{"grp-admins", "grp-ops"}, got.AccessGroups)
})
}
// Restrictions are stored as a JSON blob, and the Postgres read path lists
// columns by hand: a mode that is not read there is silently off on Postgres
// while working in SQLite dev.
func TestSqlStore_GetAccount_ServiceRestrictionsRoundtrip(t *testing.T) {
if os.Getenv("CI") == "true" && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") {
t.Skip("skip CI tests on darwin and windows")
}
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
ctx := context.Background()
account := newAccountWithId(ctx, "account_svc_restrictions", "testuser", "")
require.NoError(t, store.SaveAccount(ctx, account))
svc := &rpservice.Service{
ID: "svc-restrictions",
AccountID: account.Id,
Name: "restricted-svc",
Domain: "restricted.example",
Enabled: true,
Mode: rpservice.ModeHTTP,
Restrictions: rpservice.AccessRestrictions{
AllowedCIDRs: []string{"203.0.113.0/24"},
CrowdSecMode: "observe",
AppSecMode: "enforce",
},
}
require.NoError(t, store.CreateService(ctx, svc))
loaded, err := store.GetAccount(ctx, account.Id)
require.NoError(t, err)
require.Len(t, loaded.Services, 1)
got := loaded.Services[0].Restrictions
assert.Equal(t, []string{"203.0.113.0/24"}, got.AllowedCIDRs)
assert.Equal(t, "observe", got.CrowdSecMode)
assert.Equal(t, "enforce", got.AppSecMode)
})
}

View File

@@ -321,7 +321,6 @@ type Store interface {
GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error
GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error)

View File

@@ -1835,20 +1835,6 @@ func (mr *MockStoreMockRecorder) GetClusterRequireSubdomain(ctx, clusterAddr int
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterRequireSubdomain", reflect.TypeOf((*MockStore)(nil).GetClusterRequireSubdomain), ctx, clusterAddr)
}
// GetClusterSupportsAppSec mocks base method.
func (m *MockStore) GetClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetClusterSupportsAppSec", ctx, clusterAddr)
ret0, _ := ret[0].(*bool)
return ret0
}
// GetClusterSupportsAppSec indicates an expected call of GetClusterSupportsAppSec.
func (mr *MockStoreMockRecorder) GetClusterSupportsAppSec(ctx, clusterAddr interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsAppSec", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsAppSec), ctx, clusterAddr)
}
// GetClusterSupportsCrowdSec mocks base method.
func (m *MockStore) GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()

View File

@@ -79,9 +79,6 @@ var (
geoDataDir string
crowdsecAPIURL string
crowdsecAPIKey string
appsecURL string
appsecTimeout time.Duration
appsecMaxBodyBytes int64
)
var rootCmd = &cobra.Command{
@@ -128,9 +125,6 @@ func init() {
rootCmd.Flags().StringVar(&geoDataDir, "geo-data-dir", envStringOrDefault("NB_PROXY_GEO_DATA_DIR", "/var/lib/netbird/geolocation"), "Directory for the GeoLite2 MMDB file (auto-downloaded if missing)")
rootCmd.Flags().StringVar(&crowdsecAPIURL, "crowdsec-api-url", envStringOrDefault("NB_PROXY_CROWDSEC_API_URL", ""), "CrowdSec LAPI URL for IP reputation checks")
rootCmd.Flags().StringVar(&crowdsecAPIKey, "crowdsec-api-key", envStringOrDefault("NB_PROXY_CROWDSEC_API_KEY", ""), "CrowdSec bouncer API key")
rootCmd.Flags().StringVar(&appsecURL, "crowdsec-appsec-url", envStringOrDefault("NB_PROXY_CROWDSEC_APPSEC_URL", ""), "CrowdSec AppSec (WAF) endpoint for HTTP request inspection, e.g. http://127.0.0.1:7422/ (reuses the bouncer API key)")
rootCmd.Flags().DurationVar(&appsecTimeout, "crowdsec-appsec-timeout", envDurationOrDefault("NB_PROXY_CROWDSEC_APPSEC_TIMEOUT", 0), "Timeout for a single AppSec inspection call (0 = 200ms)")
rootCmd.Flags().Int64Var(&appsecMaxBodyBytes, "crowdsec-appsec-max-body-bytes", envInt64OrDefault("NB_PROXY_CROWDSEC_APPSEC_MAX_BODY_BYTES", 0), "Cap on the request body mirrored to AppSec (0 = 64KiB, negative = headers and URI only)")
}
// Execute runs the root command.
@@ -224,57 +218,47 @@ func runServer(cmd *cobra.Command, args []string) error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
srv := proxy.New(ctx, serverConfig(logger, proxyToken, parsedTrustedProxies, perf))
srv := proxy.New(ctx, proxy.Config{
ListenAddr: addr,
Logger: logger,
Version: Version,
ManagementAddress: mgmtAddr,
ProxyURL: proxyDomain,
ProxyToken: proxyToken,
CertificateDirectory: certDir,
CertificateFile: certFile,
CertificateKeyFile: certKeyFile,
GenerateACMECertificates: acmeCerts,
ACMEChallengeAddress: acmeAddr,
ACMEDirectory: acmeDir,
ACMEEABKID: acmeEABKID,
ACMEEABHMACKey: acmeEABHMACKey,
ACMEChallengeType: acmeChallengeType,
DebugEndpointEnabled: debugEndpoint,
DebugEndpointAddress: debugEndpointAddr,
HealthAddr: healthAddr,
ForwardedProto: forwardedProto,
TrustedProxies: parsedTrustedProxies,
CertLockMethod: nbacme.CertLockMethod(certLockMethod),
WildcardCertDir: wildcardCertDir,
WireguardPort: wgPort,
Performance: perf,
ProxyProtocol: proxyProtocol,
PreSharedKey: preSharedKey,
SupportsCustomPorts: supportsCustomPorts,
RequireSubdomain: requireSubdomain,
Private: private,
MaxDialTimeout: maxDialTimeout,
MaxSessionIdleTimeout: maxSessionIdleTimeout,
MappingBatchWatchdog: envDurationOrDefault("NB_PROXY_MAPPING_BATCH_WATCHDOG", 0),
GeoDataDir: geoDataDir,
CrowdSecAPIURL: crowdsecAPIURL,
CrowdSecAPIKey: crowdsecAPIKey,
})
return srv.ListenAndServe(ctx, addr)
}
// serverConfig maps the parsed flags and environment onto the proxy config.
// Kept separate from runServer so registering a new flag does not grow the
// startup path.
func serverConfig(logger *log.Logger, proxyToken string, trustedProxyList *trustedproxy.List, perf embed.Performance) proxy.Config {
return proxy.Config{
ListenAddr: addr,
Logger: logger,
Version: Version,
ManagementAddress: mgmtAddr,
ProxyURL: proxyDomain,
ProxyToken: proxyToken,
CertificateDirectory: certDir,
CertificateFile: certFile,
CertificateKeyFile: certKeyFile,
GenerateACMECertificates: acmeCerts,
ACMEChallengeAddress: acmeAddr,
ACMEDirectory: acmeDir,
ACMEEABKID: acmeEABKID,
ACMEEABHMACKey: acmeEABHMACKey,
ACMEChallengeType: acmeChallengeType,
DebugEndpointEnabled: debugEndpoint,
DebugEndpointAddress: debugEndpointAddr,
HealthAddr: healthAddr,
ForwardedProto: forwardedProto,
TrustedProxies: trustedProxyList,
CertLockMethod: nbacme.CertLockMethod(certLockMethod),
WildcardCertDir: wildcardCertDir,
WireguardPort: wgPort,
Performance: perf,
ProxyProtocol: proxyProtocol,
PreSharedKey: preSharedKey,
SupportsCustomPorts: supportsCustomPorts,
RequireSubdomain: requireSubdomain,
Private: private,
MaxDialTimeout: maxDialTimeout,
MaxSessionIdleTimeout: maxSessionIdleTimeout,
MappingBatchWatchdog: envDurationOrDefault("NB_PROXY_MAPPING_BATCH_WATCHDOG", 0),
GeoDataDir: geoDataDir,
CrowdSecAPIURL: crowdsecAPIURL,
CrowdSecAPIKey: crowdsecAPIKey,
CrowdSecAppSecURL: appsecURL,
CrowdSecAppSecTimeout: appsecTimeout,
CrowdSecAppSecMaxBodyBytes: appsecMaxBodyBytes,
}
}
func envBoolOrDefault(key string, def bool) bool {
v, exists := os.LookupEnv(key)
if !exists {
@@ -309,19 +293,6 @@ func envUint16OrDefault(key string, def uint16) uint16 {
return uint16(parsed)
}
func envInt64OrDefault(key string, def int64) int64 {
v, exists := os.LookupEnv(key)
if !exists {
return def
}
parsed, err := strconv.ParseInt(v, 10, 64)
if err != nil {
log.Warnf("parse %s=%q: %v, using default %d", key, v, err, def)
return def
}
return parsed
}
func envDurationOrDefault(key string, def time.Duration) time.Duration {
v, exists := os.LookupEnv(key)
if !exists {

View File

@@ -1,163 +0,0 @@
package appsec
import (
"bytes"
"errors"
"io"
"mime"
"net/http"
"net/url"
"slices"
"strings"
)
// bufferBody reads up to limit+1 bytes from r.Body and always restores r.Body so
// the request stays forwardable. oversize reports that the body exceeded limit, in
// which case the returned prefix must not be used for inspection: the bytes are
// only read so they can be replayed to the backend.
func bufferBody(r *http.Request, limit int64) (body []byte, oversize bool, err error) {
original := r.Body
buf, readErr := io.ReadAll(io.LimitReader(original, limit+1))
if readErr != nil && !errors.Is(readErr, io.EOF) {
// Restore what was read so a downstream retry sees a consistent stream,
// then surface the failure.
r.Body = replay(buf, original)
return nil, false, readErr
}
if int64(len(buf)) > limit {
r.Body = replay(buf, original)
return nil, true, nil
}
// The whole body is buffered, so the original is drained and can be closed.
// A close error on a drained read-only body does not invalidate the bytes.
_ = original.Close()
r.Body = io.NopCloser(bytes.NewReader(buf))
// Framing is deliberately left as the client sent it. Rewriting a chunked
// request to a fixed Content-Length here would be invisible to the client
// but not to the rest of the chain: a later body capture with a smaller cap
// sees a known length over its cap and skips capture entirely, where an
// unknown length would have given it a truncated prefix. Inspecting a
// request must not change what any other layer gets to inspect.
return buf, false, nil
}
// replay returns a ReadCloser that yields the already-read prefix followed by
// the remainder of the original stream, and closes the original.
func replay(prefix []byte, rest io.ReadCloser) io.ReadCloser {
return struct {
io.Reader
io.Closer
}{
Reader: io.MultiReader(bytes.NewReader(prefix), rest),
Closer: rest,
}
}
// redactedPlaceholder replaces a credential value in the mirrored body. It is
// inert for rule matching, and its fixed length leaks nothing about the secret.
const redactedPlaceholder = "redacted"
// redactFormFields returns the body to mirror for a URL-encoded form, with the
// values of the named fields replaced. The proxy's own password / PIN login
// form posts to the service path itself, so without this the plaintext
// credential would reach the Security Engine.
//
// Only the credential values are removed, never the whole body: dropping the
// body outright would let a caller exempt any payload from inspection just by
// appending a field named "password". Everything else in the form stays
// inspectable, which is the point.
//
// Returns body unchanged when it is not a URL-encoded form or carries none of
// the fields.
//
// Substitution happens on the raw bytes rather than by re-encoding parsed
// values. Re-encoding would drop pairs that url.ParseQuery rejects, so a
// payload hidden in a malformed pair alongside a credential-named field would
// never be inspected while a tolerant backend parser still acted on it. Working
// byte-wise also avoids reordering keys and normalizing escapes, so the engine
// sees the same bytes the backend will.
//
// Field names match case-sensitively, on purpose: the caller passes the exact
// names the login handler reads via r.FormValue, and that lookup is itself
// case-sensitive. A "Password" field is therefore never a credential as far as
// the proxy is concerned, and redacting it would only blind the WAF to a value
// the proxy does not own.
func redactFormFields(contentType string, body []byte, fields []string) []byte {
if len(fields) == 0 || len(body) == 0 {
return body
}
media, _, err := mime.ParseMediaType(contentType)
if err != nil || media != "application/x-www-form-urlencoded" {
return body
}
return redactURLEncoded(body, fields)
}
// redactURLEncoded replaces the values of the named keys in a URL-encoded
// key/value sequence, the shared syntax of a query string and a form body.
func redactURLEncoded(raw []byte, fields []string) []byte {
// Split on "&" only, matching how Go's form parser delimits pairs.
segments := bytes.Split(raw, []byte("&"))
redacted := false
for i, segment := range segments {
rawKey, _, hasValue := bytes.Cut(segment, []byte("="))
if !hasValue {
continue
}
// Compare the decoded name, so an escaped spelling of the field
// ("pass%77ord") is redacted too: the reader decodes before looking it
// up. A key that fails to decode never reaches that reader either,
// since the parser drops the pair.
name, err := url.QueryUnescape(string(rawKey))
if err != nil || !slices.Contains(fields, name) {
continue
}
// Keep the key bytes as sent and replace only the value. Assigning a
// fresh slice leaves raw untouched, which matters: the caller restored
// the request body from the same buffer.
segments[i] = []byte(string(rawKey) + "=" + redactedPlaceholder)
redacted = true
}
if !redacted {
return raw
}
return bytes.Join(segments, []byte("&"))
}
// redactQuery replaces the values of the named query parameters in a raw query
// string, leaving every other byte as sent.
func redactQuery(rawQuery string, params []string) string {
if len(params) == 0 || rawQuery == "" {
return rawQuery
}
return string(redactURLEncoded([]byte(rawQuery), params))
}
// redactCookieHeader replaces the values of the named cookies in a Cookie
// header, keeping the others intact: cookies are a zone WAF rules match on, so
// dropping the whole header would cost real coverage.
func redactCookieHeader(value string, names []string) string {
if len(names) == 0 || value == "" {
return value
}
parts := strings.Split(value, ";")
redacted := false
for i, part := range parts {
name, _, hasValue := strings.Cut(part, "=")
if !hasValue {
continue
}
// Cookie names are case-sensitive and are not percent-decoded.
if !slices.Contains(names, strings.TrimSpace(name)) {
continue
}
parts[i] = name + "=" + redactedPlaceholder
redacted = true
}
if !redacted {
return value
}
return strings.Join(parts, ";")
}

View File

@@ -1,495 +0,0 @@
// Package appsec implements the CrowdSec AppSec (WAF) side of the remediation
// component protocol: each inspected HTTP request is mirrored to the Security
// Engine's AppSec endpoint, which replies with an allow / ban / captcha verdict
// for that request.
//
// This is a separate endpoint from the LAPI decision stream used by the
// crowdsec package: LAPI answers "is this IP known bad", AppSec answers "is
// this request an attack". The two are configured and enabled independently.
package appsec
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/netip"
"net/url"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/proxy/internal/netutil"
"github.com/netbirdio/netbird/proxy/internal/restrict"
)
// Header names the AppSec component reads off the mirrored request. IP, URI and
// Verb are mandatory: the engine answers 500 when any of them is missing.
const (
headerAPIKey = "X-Crowdsec-Appsec-Api-Key" //nolint:gosec // G101: a header name, not a credential
headerIP = "X-Crowdsec-Appsec-Ip"
headerURI = "X-Crowdsec-Appsec-Uri"
headerVerb = "X-Crowdsec-Appsec-Verb"
headerHost = "X-Crowdsec-Appsec-Host"
headerUserAgent = "X-Crowdsec-Appsec-User-Agent"
headerHTTPVersion = "X-Crowdsec-Appsec-Http-Version"
headerTransactionID = "X-Crowdsec-Appsec-Transaction-Id"
)
// headerPrefix covers every protocol header. Any client-supplied header in this
// namespace is dropped before forwarding so a caller cannot influence the
// engine's view of its own address, or replay an API key.
const headerPrefix = "X-Crowdsec-Appsec-"
// Remediation actions the engine can return.
const (
actionAllow = "allow"
actionBan = "ban"
actionCaptcha = "captcha"
)
const (
// DefaultTimeout matches the 200ms budget CrowdSec's remediation component
// spec sets for the blocking AppSec call.
DefaultTimeout = 200 * time.Millisecond
// MinTimeout and MaxTimeout bound the configured inspection timeout.
// Inspection is synchronous, so the upper bound is what keeps a
// mis-set value from parking every request to an inspected service on a
// slow engine; the lower bound keeps the call from timing out before the
// engine can realistically answer. Mirrors the per-middleware bounds the
// proxy already applies to in-path calls.
MinTimeout = 10 * time.Millisecond
MaxTimeout = 5 * time.Second
// DefaultMaxBodyBytes caps the request body mirrored to the engine.
// Requests with a larger body are inspected on headers and URI only.
DefaultMaxBodyBytes int64 = 64 << 10
// MaxBodyBytesLimit is the ceiling for that cap. A single request can hold
// this much in memory; the shared Budget is what bounds the total across
// concurrent requests. Matches the proxy-wide body-capture ceiling.
MaxBodyBytesLimit int64 = 8 << 20
// maxResponseBytes bounds how much of a verdict response is read. The
// engine answers with a two-field JSON object, so anything beyond this is
// not a response we can act on.
maxResponseBytes int64 = 4 << 10
)
// Reasons the request body was not mirrored. Reported so an access-log reader
// can distinguish "inspected and clean" from "never inspected", and so an
// oversize opt-out is visible rather than silent.
const (
BypassOversize = "oversize"
BypassUpgrade = "upgrade"
BypassDisabled = "disabled"
BypassBudget = "budget_exhausted"
)
// ErrUnavailable reports that the engine could not produce a verdict: the call
// failed, timed out, or the engine rejected it (401 bad key, 500 malformed).
// Distinguished from a block verdict so the caller can apply the per-service
// mode: enforce fails closed, observe allows.
var ErrUnavailable = errors.New("appsec engine unavailable")
// Config configures a Client.
type Config struct {
// URL is the AppSec endpoint, e.g. http://127.0.0.1:7422/.
URL string
// APIKey is the CrowdSec bouncer API key. The AppSec component validates it
// against LAPI, so the same key used for the decision stream works here.
APIKey string
// Timeout bounds a single inspection call. Zero means DefaultTimeout.
Timeout time.Duration
// MaxBodyBytes caps the mirrored request body. Zero means
// DefaultMaxBodyBytes; negative disables body forwarding entirely.
MaxBodyBytes int64
// Budget bounds the total body buffering in flight across all inspected
// requests. Nil disables that ceiling, which leaves the worst case at
// MaxBodyBytes times the concurrent request count; callers serving
// untrusted traffic should share the proxy-wide capture budget here.
Budget Budget
Logger *log.Entry
}
// Budget is the shared allowance for in-flight body buffering. Acquire reports
// whether n bytes could be reserved; every successful Acquire is matched by a
// Release of the same n. Satisfied by the proxy's capture budget, so AppSec and
// the middleware body tap draw down one pool rather than two independent ones.
type Budget interface {
Acquire(n int64) bool
Release(n int64)
}
// Client mirrors HTTP requests to a CrowdSec AppSec endpoint. It holds no
// per-service state and is safe for concurrent use.
type Client struct {
url string
apiKey string
maxBodyBytes int64
budget Budget
http *http.Client
logger *log.Entry
}
// New validates the config and returns a Client. The endpoint is not contacted
// here: the engine may come up after the proxy.
func New(cfg Config) (*Client, error) {
if cfg.URL == "" {
return nil, errors.New("appsec url is empty")
}
if cfg.APIKey == "" {
return nil, errors.New("appsec api key is empty")
}
parsed, err := url.Parse(cfg.URL)
if err != nil {
return nil, fmt.Errorf("parse appsec url: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("appsec url scheme %q is not http(s)", parsed.Scheme)
}
if parsed.Host == "" {
return nil, errors.New("appsec url has no host")
}
logger := cfg.Logger
if logger == nil {
logger = log.NewEntry(log.StandardLogger())
}
timeout := cfg.Timeout
switch {
case timeout <= 0:
timeout = DefaultTimeout
case timeout < MinTimeout:
logger.Warnf("appsec timeout %s is below the minimum, using %s", timeout, MinTimeout)
timeout = MinTimeout
case timeout > MaxTimeout:
logger.Warnf("appsec timeout %s exceeds the maximum, using %s", timeout, MaxTimeout)
timeout = MaxTimeout
}
// A negative cap is meaningful: forward no body at all.
maxBody := cfg.MaxBodyBytes
switch {
case maxBody == 0:
maxBody = DefaultMaxBodyBytes
case maxBody > MaxBodyBytesLimit:
logger.Warnf("appsec max body %d exceeds the maximum, using %d", maxBody, MaxBodyBytesLimit)
maxBody = MaxBodyBytesLimit
}
return &Client{
url: cfg.URL,
apiKey: cfg.APIKey,
maxBodyBytes: maxBody,
budget: cfg.Budget,
logger: logger,
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 90 * time.Second,
},
},
}, nil
}
// Request is one inspection request.
type Request struct {
// HTTP is the in-flight client request. Inspect buffers and restores its
// body, so the request stays forwardable afterwards.
HTTP *http.Request
// ClientIP is the resolved client address (after trusted-proxy handling).
ClientIP netip.Addr
// TransactionID correlates the engine's alert with the proxy's access log
// entry. Empty lets the engine generate its own UUID.
TransactionID string
// RedactBodyFields lists form fields whose values are replaced before the
// body is mirrored. Used to keep credentials submitted to the proxy's own
// login form out of the engine while still inspecting the rest.
RedactBodyFields []string
// RedactHeaders, RedactCookies and RedactQueryParams name the credentials
// the proxy already withholds from backends: the header-auth values, its
// session cookie, and the OIDC session token. The engine logs and alerts on
// what it inspects, so mirroring them there would reintroduce the leak the
// upstream strippers exist to prevent. Only the values are replaced, so the
// surrounding headers, cookies and query stay inspectable.
RedactHeaders []string
RedactCookies []string
RedactQueryParams []string
}
// Result is the outcome of an inspection.
type Result struct {
Verdict restrict.Verdict
// BodyBypass names why the request body was not mirrored, empty when it
// was (or when the request had none). The engine still saw the headers and
// URI, so this is a coverage note, not a failure.
BodyBypass string
// Release returns the buffered body's budget reservation. Never nil, so it
// is always safe to defer. It must run only once the request has been
// served, not when Inspect returns: the buffer stays alive as r.Body for
// the backend to read, so releasing earlier would let the budget admit
// buffering that is still resident.
Release func()
}
// noopRelease is the Release for inspections that reserved no budget.
func noopRelease() {}
// Inspect mirrors r to the AppSec engine and returns its verdict. A nil error
// with restrict.Allow means the request passed. On failure it returns
// DenyAppSecUnavailable wrapped with ErrUnavailable; the caller decides whether
// that blocks, based on the per-service mode.
func (c *Client) Inspect(ctx context.Context, req Request) (Result, error) {
if c == nil {
return Result{Verdict: restrict.DenyAppSecUnavailable, Release: noopRelease}, ErrUnavailable
}
if req.HTTP == nil {
return Result{Verdict: restrict.DenyAppSecUnavailable, Release: noopRelease}, fmt.Errorf("%w: nil request", ErrUnavailable)
}
// release is carried out to the caller rather than deferred here: the
// buffered body outlives this call as r.Body.
body, bypass, release, err := c.readBody(req)
if err != nil {
return Result{Verdict: restrict.DenyAppSecUnavailable, Release: release}, fmt.Errorf("%w: read body: %w", ErrUnavailable, err)
}
outbound, err := c.buildRequest(ctx, req, body)
if err != nil {
return Result{Verdict: restrict.DenyAppSecUnavailable, BodyBypass: bypass, Release: release}, fmt.Errorf("%w: %w", ErrUnavailable, err)
}
resp, err := c.http.Do(outbound)
if err != nil {
return Result{Verdict: restrict.DenyAppSecUnavailable, BodyBypass: bypass, Release: release}, fmt.Errorf("%w: %w", ErrUnavailable, err)
}
defer func() {
// Drain before closing. net/http only returns a connection to the idle
// pool once its body is read to EOF; closing with bytes outstanding
// discards it. Every verdict carries a JSON body, so skipping this
// would cost a fresh handshake per inspected request, inside the
// timeout budget.
if _, err := io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBytes)); err != nil {
c.logger.Tracef("drain appsec response body: %v", err)
}
if err := resp.Body.Close(); err != nil {
c.logger.Tracef("close appsec response body: %v", err)
}
}()
verdict, err := c.verdict(resp)
return Result{Verdict: verdict, BodyBypass: bypass, Release: release}, err
}
// readBody buffers the body so it can be mirrored, always restoring it on the
// original request. Returns nil when there is no body to forward: no body at
// all, an upgrade request, or a body over the cap. A login form is forwarded
// with its credential values redacted rather than suppressed.
// release is never nil; the caller invokes it once the request has been served.
func (c *Client) readBody(req Request) (body []byte, bypass string, release func(), err error) {
r := req.HTTP
if r.Body == nil || r.Body == http.NoBody {
return nil, "", noopRelease, nil
}
if c.maxBodyBytes < 0 {
return nil, BypassDisabled, noopRelease, nil
}
// A genuine upgrade request carries no body to inspect (net/http hands us
// http.NoBody, caught above); the hijacked stream is reached through
// Hijacker, never r.Body. The test has to be the forwarder's own, because a
// looser one would skip inspection for requests the forwarder still
// delivers to the backend with their body intact.
if netutil.IsUpgradeRequest(r.Header) {
return nil, BypassUpgrade, noopRelease, nil
}
// A Content-Length over the cap is known to be too large before reading.
if r.ContentLength > c.maxBodyBytes {
return nil, BypassOversize, noopRelease, nil
}
// Reserve the whole cap rather than the eventual length: the reservation
// has to be made before the body is read, and until then the only bound
// known is the cap. Skipping inspection when the pool is drained keeps a
// burst of large bodies from being an out-of-memory lever; the bypass is
// recorded so the gap in coverage is visible.
release = noopRelease
if c.budget != nil {
if !c.budget.Acquire(c.maxBodyBytes) {
c.logger.Debugf("appsec buffer budget exhausted, inspecting headers and URI only")
return nil, BypassBudget, noopRelease, nil
}
var once sync.Once
release = func() { once.Do(func() { c.budget.Release(c.maxBodyBytes) }) }
}
buffered, oversize, err := bufferBody(r, c.maxBodyBytes)
if err != nil {
// bufferBody restored r.Body from the bytes it did read, so the
// reservation stays held until the caller releases it.
return nil, "", release, err
}
// An oversize body was only partially read: a truncated prefix changes the
// engine's verdict in both directions, so inspect headers and URI only.
if oversize {
return nil, BypassOversize, release, nil
}
return redactFormFields(r.Header.Get("Content-Type"), buffered, req.RedactBodyFields), "", release, nil
}
// buildRequest assembles the mirrored request. Per the protocol it is a GET
// when there is no body and a POST otherwise; bytes.Reader gives the outbound
// request an accurate Content-Length, which the engine relies on to read the
// body at all.
func (c *Client) buildRequest(ctx context.Context, req Request, body []byte) (*http.Request, error) {
method := http.MethodGet
var payload io.Reader
if len(body) > 0 {
method = http.MethodPost
payload = bytes.NewReader(body)
}
outbound, err := http.NewRequestWithContext(ctx, method, c.url, payload)
if err != nil {
return nil, fmt.Errorf("build appsec request: %w", err)
}
r := req.HTTP
copyInspectableHeaders(outbound.Header, r.Header)
redactSecrets(outbound.Header, req)
outbound.Header.Set(headerAPIKey, c.apiKey)
outbound.Header.Set(headerIP, req.ClientIP.Unmap().String())
outbound.Header.Set(headerURI, mirroredURI(r.URL, req.RedactQueryParams))
outbound.Header.Set(headerVerb, r.Method)
outbound.Header.Set(headerHost, r.Host)
if ua := r.UserAgent(); ua != "" {
outbound.Header.Set(headerUserAgent, ua)
}
outbound.Header.Set(headerHTTPVersion, httpVersion(r))
if req.TransactionID != "" {
outbound.Header.Set(headerTransactionID, req.TransactionID)
}
return outbound, nil
}
// verdict maps the engine's response to a restrict.Verdict. 200 is a pass and
// 401/500 are engine-side failures; every other status carries a remediation in
// the body. The blocked status code is operator-configurable
// (blocked_http_code), so the action field decides, not the status.
func (c *Client) verdict(resp *http.Response) (restrict.Verdict, error) {
switch resp.StatusCode {
case http.StatusOK:
return restrict.Allow, nil
case http.StatusUnauthorized:
return restrict.DenyAppSecUnavailable, fmt.Errorf("%w: rejected api key", ErrUnavailable)
case http.StatusInternalServerError:
return restrict.DenyAppSecUnavailable, fmt.Errorf("%w: engine error", ErrUnavailable)
}
var decoded struct {
Action string `json:"action"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(&decoded); err != nil {
// Every remediation carries a decodable action, so a response without
// one is not a verdict: most often the URL points at something that is
// not the AppSec endpoint, which answers 404 with HTML. Reported as
// unavailable rather than a ban so the access log names the real fault
// instead of sending an operator hunting for a rule that never fired.
// Enforce still blocks either way; only the recorded reason differs.
return restrict.DenyAppSecUnavailable, fmt.Errorf("%w: undecodable response (status %d): %w", ErrUnavailable, resp.StatusCode, err)
}
switch decoded.Action {
case actionAllow:
return restrict.Allow, nil
case actionCaptcha:
return restrict.DenyAppSecCaptcha, nil
case actionBan:
return restrict.DenyAppSecBan, nil
default:
// Unknown remediation: the engine flagged the request, so deny.
c.logger.Debugf("unknown appsec action %q (status %d), treating as ban", decoded.Action, resp.StatusCode)
return restrict.DenyAppSecBan, nil
}
}
// copyInspectableHeaders copies the client's headers, which are what the WAF
// rules actually match on, dropping hop-by-hop headers that describe the
// proxy-to-engine connection rather than the client request, and any header in
// the AppSec protocol namespace.
func copyInspectableHeaders(dst, src http.Header) {
for name, values := range src {
if hopByHopHeaders[http.CanonicalHeaderKey(name)] {
continue
}
if strings.HasPrefix(http.CanonicalHeaderKey(name), headerPrefix) {
continue
}
dst[http.CanonicalHeaderKey(name)] = append([]string(nil), values...)
}
// Content-Length describes the mirrored payload, not the client's: net/http
// sets it from the body we actually attach. Content-Type is kept either way
// so rules matching on it still fire when the body was not forwarded.
dst.Del("Content-Length")
}
// redactSecrets replaces the credential values the proxy withholds from
// backends, so the mirrored copy does not carry them either.
func redactSecrets(dst http.Header, req Request) {
for _, name := range req.RedactHeaders {
// Presence, not Get: a header whose first value is empty still carries
// its later values to the engine, while the upstream strip deletes the
// name outright. Set collapses every value into the placeholder.
if len(dst.Values(name)) > 0 {
dst.Set(name, redactedPlaceholder)
}
}
if cookie := dst.Get("Cookie"); cookie != "" {
if redacted := redactCookieHeader(cookie, req.RedactCookies); redacted != cookie {
dst.Set("Cookie", redacted)
}
}
}
// mirroredURI renders the request target for the URI header, with the named
// query parameter values replaced.
func mirroredURI(u *url.URL, redactParams []string) string {
uri := u.RequestURI()
if u.RawQuery == "" || len(redactParams) == 0 {
return uri
}
redacted := redactQuery(u.RawQuery, redactParams)
if redacted == u.RawQuery {
return uri
}
// RequestURI is path + "?" + RawQuery; swap only the query part so the
// path keeps its original encoding.
return strings.TrimSuffix(uri, u.RawQuery) + redacted
}
var hopByHopHeaders = map[string]bool{
"Connection": true,
"Keep-Alive": true,
"Proxy-Authenticate": true,
"Proxy-Authorization": true,
"Proxy-Connection": true,
"Te": true,
"Trailer": true,
"Transfer-Encoding": true,
"Upgrade": true,
}
// httpVersion renders the two-digit form the engine parses ("11", "20").
func httpVersion(r *http.Request) string {
major, minor := r.ProtoMajor, r.ProtoMinor
if major < 0 || major > 9 || minor < 0 || minor > 9 {
return ""
}
return fmt.Sprintf("%d%d", major, minor)
}

View File

@@ -1,878 +0,0 @@
package appsec
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/proxy/internal/restrict"
)
// engine records what the AppSec component received and replies with a
// canned status and body.
type engine struct {
status int
body string
gotMethod string
gotHeader http.Header
gotBody []byte
gotLength int64
requests int
}
func (e *engine) start(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
e.requests++
e.gotMethod = r.Method
e.gotHeader = r.Header.Clone()
e.gotBody = body
e.gotLength = r.ContentLength
status := e.status
if status == 0 {
status = http.StatusOK
}
w.WriteHeader(status)
if e.body != "" {
_, _ = w.Write([]byte(e.body))
}
}))
t.Cleanup(srv.Close)
return srv
}
func newClient(t *testing.T, url string, cfg ...func(*Config)) *Client {
t.Helper()
c := Config{URL: url, APIKey: "test-key"}
for _, fn := range cfg {
fn(&c)
}
client, err := New(c)
require.NoError(t, err)
return client
}
func inbound(method, target string, body string) *http.Request {
var r *http.Request
if body == "" {
r = httptest.NewRequest(method, target, nil)
} else {
r = httptest.NewRequest(method, target, strings.NewReader(body))
}
r.Host = "svc.example.com"
r.Header.Set("User-Agent", "curl/8.0")
return r
}
func TestNew_RejectsBadConfig(t *testing.T) {
tests := []struct {
name string
cfg Config
}{
{"empty url", Config{APIKey: "k"}},
{"empty key", Config{URL: "http://127.0.0.1:7422/"}},
{"non http scheme", Config{URL: "tcp://127.0.0.1:7422", APIKey: "k"}},
{"no host", Config{URL: "http:///path", APIKey: "k"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := New(tt.cfg)
require.Error(t, err)
})
}
}
func TestInspect_AllowSendsProtocolHeaders(t *testing.T) {
eng := &engine{status: http.StatusOK, body: `{"action":"allow","http_status":200}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
r := inbound(http.MethodGet, "http://svc.example.com/admin?q=1", "")
r.Header.Set("Cookie", "session=abc")
res, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
TransactionID: "req-42",
})
require.NoError(t, err)
assert.Equal(t, restrict.Allow, res.Verdict, "allow action must pass the request")
assert.Equal(t, http.MethodGet, eng.gotMethod, "a bodyless request is forwarded as GET")
assert.Equal(t, "203.0.113.7", eng.gotHeader.Get(headerIP))
assert.Equal(t, "/admin?q=1", eng.gotHeader.Get(headerURI), "URI header must carry path and query")
assert.Equal(t, http.MethodGet, eng.gotHeader.Get(headerVerb))
assert.Equal(t, "svc.example.com", eng.gotHeader.Get(headerHost))
assert.Equal(t, "curl/8.0", eng.gotHeader.Get(headerUserAgent))
assert.Equal(t, "11", eng.gotHeader.Get(headerHTTPVersion))
assert.Equal(t, "req-42", eng.gotHeader.Get(headerTransactionID))
assert.Equal(t, "test-key", eng.gotHeader.Get(headerAPIKey))
// Client headers are what the WAF rules match on.
assert.Equal(t, "session=abc", eng.gotHeader.Get("Cookie"))
}
func TestInspect_MapsActionsToVerdicts(t *testing.T) {
tests := []struct {
name string
status int
body string
want restrict.Verdict
wantErr bool
}{
{"allow", http.StatusOK, `{"action":"allow"}`, restrict.Allow, false},
{"ban", http.StatusForbidden, `{"action":"ban","http_status":403}`, restrict.DenyAppSecBan, false},
{"captcha", http.StatusForbidden, `{"action":"captcha","http_status":403}`, restrict.DenyAppSecCaptcha, false},
// blocked_http_code is operator-configurable, so the action decides.
{"custom block status", http.StatusTeapot, `{"action":"ban"}`, restrict.DenyAppSecBan, false},
{"allow on custom status", http.StatusTeapot, `{"action":"allow"}`, restrict.Allow, false},
{"unknown action denies", http.StatusForbidden, `{"action":"something-new"}`, restrict.DenyAppSecBan, false},
// No decodable action means no remediation was communicated, so this is
// an engine fault, not a ban. Enforce blocks on it either way.
{"unreadable body is unavailable", http.StatusForbidden, `not json`, restrict.DenyAppSecUnavailable, true},
// A misdirected URL answering 404 with HTML must not read as a ban.
{"non-appsec endpoint is unavailable", http.StatusNotFound, `<html>404</html>`, restrict.DenyAppSecUnavailable, true},
{"bad api key is unavailable", http.StatusUnauthorized, "", restrict.DenyAppSecUnavailable, true},
{"engine error is unavailable", http.StatusInternalServerError, "", restrict.DenyAppSecUnavailable, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
eng := &engine{status: tt.status, body: tt.body}
srv := eng.start(t)
client := newClient(t, srv.URL)
res, err := client.Inspect(context.Background(), Request{
HTTP: inbound(http.MethodGet, "http://svc.example.com/", ""),
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
if tt.wantErr {
require.Error(t, err)
assert.True(t, errors.Is(err, ErrUnavailable), "engine-side failures must be ErrUnavailable so the caller can apply the mode")
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.want, res.Verdict)
})
}
}
func TestInspect_ForwardsBodyAndRestoresIt(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
const payload = `{"user":"' OR 1=1--"}`
r := inbound(http.MethodPost, "http://svc.example.com/login", payload)
r.Header.Set("Content-Type", "application/json")
_, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
require.NoError(t, err)
assert.Equal(t, http.MethodPost, eng.gotMethod, "a request with a body is forwarded as POST")
assert.Equal(t, payload, string(eng.gotBody))
assert.Equal(t, int64(len(payload)), eng.gotLength,
"the engine reads exactly Content-Length bytes, so it must be accurate")
// The backend still needs the body.
restored, err := io.ReadAll(r.Body)
require.NoError(t, err)
assert.Equal(t, payload, string(restored), "body must be restored for the upstream request")
}
func TestInspect_OversizeBodyFallsBackToHeaders(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL, func(c *Config) { c.MaxBodyBytes = 16 })
payload := strings.Repeat("A", 64)
r := inbound(http.MethodPost, "http://svc.example.com/upload", payload)
_, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
require.NoError(t, err)
assert.Equal(t, http.MethodGet, eng.gotMethod, "an oversize body is not forwarded")
assert.Empty(t, eng.gotBody, "a truncated prefix must never be sent: it changes the verdict")
restored, err := io.ReadAll(r.Body)
require.NoError(t, err)
assert.Equal(t, payload, string(restored), "the full body must still reach the upstream")
}
func TestInspect_ChunkedOversizeBodyIsReplayed(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL, func(c *Config) { c.MaxBodyBytes = 8 })
payload := strings.Repeat("B", 40)
r := inbound(http.MethodPost, "http://svc.example.com/upload", payload)
// Unknown length: the cap can only be detected while reading.
r.ContentLength = -1
r.Header.Del("Content-Length")
_, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
require.NoError(t, err)
assert.Empty(t, eng.gotBody)
restored, err := io.ReadAll(r.Body)
require.NoError(t, err)
assert.Equal(t, payload, string(restored), "bytes read to detect the cap must be replayed")
}
func TestInspect_RedactsCredentialForm(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
r := inbound(http.MethodPost, "http://svc.example.com/", "password=hunter2&next=%2Fhome")
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
_, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
RedactBodyFields: []string{"password", "pin"},
})
require.NoError(t, err)
assert.NotContains(t, string(eng.gotBody), "hunter2", "the credential must not reach the engine")
assert.Contains(t, string(eng.gotBody), "next=%2Fhome", "the rest of the form stays inspectable")
assert.Equal(t, 1, eng.requests)
restored, err := io.ReadAll(r.Body)
require.NoError(t, err)
assert.Equal(t, "password=hunter2&next=%2Fhome", string(restored),
"the login handler still needs to read the real form")
}
// A caller must not be able to exempt a payload from inspection by naming one
// of its fields "password": only the credential value is dropped.
func TestInspect_RedactionIsNotABodyBypass(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
const payload = "q=%27+OR+1%3D1--&password=x"
r := inbound(http.MethodPost, "http://svc.example.com/search", payload)
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
_, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
RedactBodyFields: []string{"password", "pin"},
})
require.NoError(t, err)
forwarded := string(eng.gotBody)
assert.Contains(t, forwarded, "OR+1%3D1", "the attack payload must still be inspected")
assert.NotContains(t, forwarded, "password=x")
}
// A body that fails to parse carries no credential the login handler could
// read either, so it is inspected as-is rather than withheld.
func TestInspect_MalformedFormIsStillInspected(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
const payload = "q=%zz&evil=%3Cscript%3E"
r := inbound(http.MethodPost, "http://svc.example.com/search", payload)
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
_, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
RedactBodyFields: []string{"password", "pin"},
})
require.NoError(t, err)
assert.Equal(t, payload, string(eng.gotBody))
}
func TestInspect_ForwardsNonCredentialForm(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
r := inbound(http.MethodPost, "http://svc.example.com/search", "q=%3Cscript%3E")
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
_, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
RedactBodyFields: []string{"password", "pin"},
})
require.NoError(t, err)
assert.Equal(t, "q=%3Cscript%3E", string(eng.gotBody), "ordinary form bodies must be inspected")
}
func TestInspect_StripsSpoofedProtocolHeaders(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
r := inbound(http.MethodGet, "http://svc.example.com/", "")
// A caller trying to make the engine see a different source address, and to
// smuggle in its own key.
r.Header.Set(headerIP, "10.0.0.1")
r.Header.Set(headerAPIKey, "attacker-key")
r.Header.Set(headerURI, "/harmless")
_, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
require.NoError(t, err)
assert.Equal(t, "203.0.113.7", eng.gotHeader.Get(headerIP), "the proxy's resolved IP must win")
assert.Equal(t, "test-key", eng.gotHeader.Get(headerAPIKey))
assert.Equal(t, "/", eng.gotHeader.Get(headerURI))
assert.Len(t, eng.gotHeader.Values(headerIP), 1, "no duplicate protocol headers")
}
func TestInspect_DropsHopByHopHeaders(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
r := inbound(http.MethodGet, "http://svc.example.com/", "")
r.Header.Set("Proxy-Authorization", "Basic zzz")
r.Header.Set("Keep-Alive", "timeout=5")
_, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
require.NoError(t, err)
assert.Empty(t, eng.gotHeader.Get("Proxy-Authorization"))
assert.Empty(t, eng.gotHeader.Get("Keep-Alive"))
}
func TestInspect_UpgradeRequestKeepsStreamIntact(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
r := inbound(http.MethodGet, "http://svc.example.com/ws", "")
r.Header.Set("Upgrade", "websocket")
r.Header.Set("Connection", "upgrade")
_, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
require.NoError(t, err)
assert.Equal(t, http.MethodGet, eng.gotMethod)
assert.Empty(t, eng.gotBody)
assert.Equal(t, 1, eng.requests, "upgrade requests are still inspected on headers")
}
func TestInspect_TimeoutIsUnavailable(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(200 * time.Millisecond)
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
client := newClient(t, srv.URL, func(c *Config) { c.Timeout = 10 * time.Millisecond })
res, err := client.Inspect(context.Background(), Request{
HTTP: inbound(http.MethodGet, "http://svc.example.com/", ""),
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrUnavailable))
assert.Equal(t, restrict.DenyAppSecUnavailable, res.Verdict)
}
func TestInspect_UnreachableEngineIsUnavailable(t *testing.T) {
// Port 1 on loopback refuses connections.
client := newClient(t, "http://127.0.0.1:1/")
res, err := client.Inspect(context.Background(), Request{
HTTP: inbound(http.MethodGet, "http://svc.example.com/", ""),
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrUnavailable))
assert.Equal(t, restrict.DenyAppSecUnavailable, res.Verdict)
}
func TestInspect_NilClientFailsClosed(t *testing.T) {
var client *Client
res, err := client.Inspect(context.Background(), Request{
HTTP: inbound(http.MethodGet, "http://svc.example.com/", ""),
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
require.Error(t, err)
assert.Equal(t, restrict.DenyAppSecUnavailable, res.Verdict)
}
func TestInspect_NegativeCapDisablesBodyForwarding(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL, func(c *Config) { c.MaxBodyBytes = -1 })
const payload = `{"a":1}`
r := inbound(http.MethodPost, "http://svc.example.com/", payload)
_, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
require.NoError(t, err)
assert.Empty(t, eng.gotBody)
restored, err := io.ReadAll(r.Body)
require.NoError(t, err)
assert.Equal(t, payload, string(restored), "an untouched body must still be forwardable")
}
func TestInspect_MapsV4MappedClientIP(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
_, err := client.Inspect(context.Background(), Request{
HTTP: inbound(http.MethodGet, "http://svc.example.com/", ""),
ClientIP: netip.MustParseAddr("::ffff:203.0.113.7"),
})
require.NoError(t, err)
assert.Equal(t, "203.0.113.7", eng.gotHeader.Get(headerIP),
"v4-mapped addresses must be unmapped so engine allowlists and rules match")
}
// Inspection is a blocking call on every request, so the connection to the
// engine must be reused. net/http only pools a connection whose body was read
// to EOF, which a verdict path returning early would skip.
func TestInspect_ReusesConnections(t *testing.T) {
var conns atomic.Int64
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"action":"allow","http_status":200}`))
}))
srv.Config.ConnState = func(_ net.Conn, state http.ConnState) {
if state == http.StateNew {
conns.Add(1)
}
}
srv.Start()
t.Cleanup(srv.Close)
client := newClient(t, srv.URL)
for range 5 {
res, err := client.Inspect(context.Background(), Request{
HTTP: inbound(http.MethodGet, "http://svc.example.com/", ""),
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
require.NoError(t, err)
require.Equal(t, restrict.Allow, res.Verdict)
}
assert.Equal(t, int64(1), conns.Load(),
"all five inspections must share one connection; a fresh handshake per request would eat the timeout budget")
}
func TestNew_ClampsOperatorValues(t *testing.T) {
tests := []struct {
name string
cfg Config
wantTimeout time.Duration
wantMaxBody int64
}{
{"defaults", Config{}, DefaultTimeout, DefaultMaxBodyBytes},
{"timeout below minimum", Config{Timeout: time.Microsecond}, MinTimeout, DefaultMaxBodyBytes},
{"timeout above maximum", Config{Timeout: time.Hour}, MaxTimeout, DefaultMaxBodyBytes},
{"body above maximum", Config{MaxBodyBytes: 1 << 30}, DefaultTimeout, MaxBodyBytesLimit},
// A negative cap means "forward no body" and must survive clamping.
{"negative body preserved", Config{MaxBodyBytes: -1}, DefaultTimeout, -1},
{"in-range values kept", Config{Timeout: time.Second, MaxBodyBytes: 1 << 10}, time.Second, 1 << 10},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.cfg.URL = "http://127.0.0.1:7422/"
tt.cfg.APIKey = "k"
client, err := New(tt.cfg)
require.NoError(t, err)
assert.Equal(t, tt.wantTimeout, client.http.Timeout, "inspection is in the request path, so the timeout must stay bounded")
assert.Equal(t, tt.wantMaxBody, client.maxBodyBytes, "buffered bodies are held per in-flight request")
})
}
}
// A payload hidden in a pair the form parser rejects must still be inspected.
// Re-encoding from parsed values would silently drop it while a tolerant
// backend parser could still act on it.
func TestInspect_RedactionDoesNotSmuggleMalformedPairs(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
const payload = "password=hunter2&%zzSMUGGLED=attack&evil=payload"
r := inbound(http.MethodPost, "http://svc.example.com/", payload)
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
_, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
RedactBodyFields: []string{"password", "pin"},
})
require.NoError(t, err)
forwarded := string(eng.gotBody)
assert.Equal(t, "password=redacted&%zzSMUGGLED=attack&evil=payload", forwarded,
"only the credential value may change; every other byte reaches the engine as sent")
assert.NotContains(t, forwarded, "hunter2")
}
// The login handler decodes the field name before looking it up, so an escaped
// spelling is a real credential submission and must be redacted too.
func TestInspect_RedactsEscapedFieldName(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
r := inbound(http.MethodPost, "http://svc.example.com/", "pass%77ord=hunter2")
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
_, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
RedactBodyFields: []string{"password"},
})
require.NoError(t, err)
assert.Equal(t, "pass%77ord=redacted", string(eng.gotBody), "the key is preserved as sent")
assert.NotContains(t, string(eng.gotBody), "hunter2")
}
func TestInspect_RedactsRepeatedCredentialField(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
r := inbound(http.MethodPost, "http://svc.example.com/", "password=one&q=keep&password=two")
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
_, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
RedactBodyFields: []string{"password"},
})
require.NoError(t, err)
assert.Equal(t, "password=redacted&q=keep&password=redacted", string(eng.gotBody))
}
// A header that does not make the forwarder treat the request as an upgrade
// must not make the inspector skip the body: the backend still receives it.
func TestInspect_FakeUpgradeHeadersDoNotSkipBody(t *testing.T) {
tests := []struct {
name string
headers map[string]string
}{
{"upgrade header alone", map[string]string{"Upgrade": "websocket"}},
{"connection token alone", map[string]string{"Connection": "upgrade"}},
{"connection keep-alive with upgrade header", map[string]string{
"Connection": "keep-alive", "Upgrade": "websocket",
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
const payload = `{"q":"' OR 1=1--"}`
r := inbound(http.MethodPost, "http://svc.example.com/api", payload)
r.Header.Set("Content-Type", "application/json")
for k, v := range tt.headers {
r.Header.Set(k, v)
}
res, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
require.NoError(t, err)
assert.Equal(t, payload, string(eng.gotBody),
"the forwarder delivers this body to the backend, so it must be inspected")
assert.Empty(t, res.BodyBypass)
})
}
}
// A real upgrade handshake carries no body, and is reported as a bypass so the
// coverage gap is visible.
func TestInspect_RealUpgradeIsReportedAsBypass(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
r := inbound(http.MethodPost, "http://svc.example.com/ws", "ignored")
r.Header.Set("Connection", "Upgrade")
r.Header.Set("Upgrade", "websocket")
res, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
require.NoError(t, err)
assert.Equal(t, BypassUpgrade, res.BodyBypass)
assert.Empty(t, eng.gotBody)
}
func TestInspect_ReportsOversizeBypass(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL, func(c *Config) { c.MaxBodyBytes = 8 })
r := inbound(http.MethodPost, "http://svc.example.com/upload", strings.Repeat("A", 64))
res, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
require.NoError(t, err)
assert.Equal(t, BypassOversize, res.BodyBypass,
"padding past the cap is attacker-controlled, so the skip must be visible")
}
// The proxy strips these three from upstream requests as credentials; the
// mirrored copy must not carry them either.
func TestInspect_RedactsCredentialsTheProxyWithholds(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
r := inbound(http.MethodGet, "http://svc.example.com/app?session_token=SECRET_JWT&q=search", "")
r.Header.Set("Cookie", "nb_session=SECRET_SESSION; theme=dark")
r.Header.Set("X-Api-Key", "SECRET_API_KEY")
_, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
RedactHeaders: []string{"X-Api-Key"},
RedactCookies: []string{"nb_session"},
RedactQueryParams: []string{"session_token"},
})
require.NoError(t, err)
assert.Equal(t, "redacted", eng.gotHeader.Get("X-Api-Key"))
assert.Equal(t, "nb_session=redacted; theme=dark", eng.gotHeader.Get("Cookie"),
"other cookies stay inspectable")
assert.Equal(t, "/app?session_token=redacted&q=search", eng.gotHeader.Get(headerURI),
"the rest of the query stays inspectable")
forwarded := eng.gotHeader.Get("Cookie") + eng.gotHeader.Get("X-Api-Key") + eng.gotHeader.Get(headerURI)
for _, secret := range []string{"SECRET_JWT", "SECRET_SESSION", "SECRET_API_KEY"} {
assert.NotContains(t, forwarded, secret)
}
}
func TestInspect_LeavesUnrelatedHeadersAndQueryIntact(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
r := inbound(http.MethodGet, "http://svc.example.com/app?q=%27+OR+1%3D1--", "")
r.Header.Set("Cookie", "theme=dark")
_, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
RedactCookies: []string{"nb_session"},
RedactQueryParams: []string{"session_token"},
})
require.NoError(t, err)
assert.Equal(t, "theme=dark", eng.gotHeader.Get("Cookie"))
assert.Equal(t, "/app?q=%27+OR+1%3D1--", eng.gotHeader.Get(headerURI),
"an untouched query keeps its original encoding")
}
// r.FormValue merges the URL query into the form, so a credential passed as a
// query parameter authenticates exactly like a form post and must not be
// mirrored in the clear either.
func TestInspect_RedactsCredentialsInQuery(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
r := inbound(http.MethodGet, "http://svc.example.com/?password=hunter2&pin=1234&q=keep", "")
_, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
RedactQueryParams: []string{"session_token", "password", "pin"},
})
require.NoError(t, err)
uri := eng.gotHeader.Get(headerURI)
assert.Equal(t, "/?password=redacted&pin=redacted&q=keep", uri)
assert.NotContains(t, uri, "hunter2")
assert.NotContains(t, uri, "1234")
}
// Inspecting a request must not change what any later layer can inspect: a
// chunked body stays chunked, so a downstream capture with a smaller cap still
// gets its truncated prefix instead of skipping on a now-known length.
func TestInspect_PreservesChunkedFraming(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
payload := strings.Repeat("A", 32)
r := inbound(http.MethodPost, "http://svc.example.com/upload", payload)
r.ContentLength = -1
r.Header.Del("Content-Length")
r.TransferEncoding = []string{"chunked"}
_, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
require.NoError(t, err)
assert.Equal(t, payload, string(eng.gotBody), "the body is still inspected")
assert.Equal(t, int64(-1), r.ContentLength, "framing must stay as the client sent it")
assert.Empty(t, r.Header.Get("Content-Length"))
assert.Equal(t, []string{"chunked"}, r.TransferEncoding)
restored, err := io.ReadAll(r.Body)
require.NoError(t, err)
assert.Equal(t, payload, string(restored))
}
// testBudget is a minimal Budget recording reservations, so a test can assert
// both that the ceiling is honoured and that it is handed back.
type testBudget struct {
mu sync.Mutex
total int64
used int64
acquires int
releases int
acquiredN []int64
}
func (b *testBudget) Acquire(n int64) bool {
b.mu.Lock()
defer b.mu.Unlock()
b.acquires++
if b.used+n > b.total {
return false
}
b.used += n
b.acquiredN = append(b.acquiredN, n)
return true
}
func (b *testBudget) Release(n int64) {
b.mu.Lock()
defer b.mu.Unlock()
b.releases++
b.used -= n
}
func (b *testBudget) inUse() int64 {
b.mu.Lock()
defer b.mu.Unlock()
return b.used
}
func TestInspect_BudgetReservesCapAndReleasesOnlyWhenCalled(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
budget := &testBudget{total: 1 << 20}
client := newClient(t, srv.URL, func(c *Config) {
c.MaxBodyBytes = 4096
c.Budget = budget
})
r := inbound(http.MethodPost, "http://svc.example.com/", "hello")
res, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
require.NoError(t, err)
require.NotNil(t, res.Release, "Release must never be nil")
// The whole cap is reserved: the reservation precedes the read, so the
// eventual body length is not yet known.
assert.Equal(t, []int64{4096}, budget.acquiredN, "the cap, not the body length, is reserved")
assert.Equal(t, int64(4096), budget.inUse(),
"the reservation must outlive Inspect: the buffered body is still r.Body for the backend")
// The backend can still read the body while the reservation is held.
got, err := io.ReadAll(r.Body)
require.NoError(t, err)
assert.Equal(t, "hello", string(got))
res.Release()
assert.Equal(t, int64(0), budget.inUse(), "Release returns the reservation")
res.Release()
assert.Equal(t, int64(0), budget.inUse(), "Release is idempotent")
}
func TestInspect_BudgetExhaustedSkipsBodyButStillInspects(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
// Nothing available, so the reservation always fails.
budget := &testBudget{total: 0}
client := newClient(t, srv.URL, func(c *Config) {
c.MaxBodyBytes = 4096
c.Budget = budget
})
r := inbound(http.MethodPost, "http://svc.example.com/", "payload")
res, err := client.Inspect(context.Background(), Request{
HTTP: r,
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
require.NoError(t, err)
defer res.Release()
assert.Equal(t, BypassBudget, res.BodyBypass, "the coverage gap must be recorded, not silent")
assert.Equal(t, restrict.Allow, res.Verdict, "headers and URI are still inspected")
assert.Empty(t, eng.gotBody, "no body is mirrored when the reservation fails")
assert.Equal(t, 0, budget.releases, "a failed Acquire must not be released")
// The body must still reach the backend untouched.
got, err := io.ReadAll(r.Body)
require.NoError(t, err)
assert.Equal(t, "payload", string(got))
}
func TestInspect_NoBudgetConfiguredStillReleases(t *testing.T) {
eng := &engine{body: `{"action":"allow"}`}
srv := eng.start(t)
client := newClient(t, srv.URL)
res, err := client.Inspect(context.Background(), Request{
HTTP: inbound(http.MethodPost, "http://svc.example.com/", "hello"),
ClientIP: netip.MustParseAddr("203.0.113.7"),
})
require.NoError(t, err)
require.NotNil(t, res.Release, "Release must be safe to defer even with no budget")
res.Release()
}

View File

@@ -1,306 +0,0 @@
package auth
import (
"crypto/ed25519"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/proxy/internal/appsec"
"github.com/netbirdio/netbird/proxy/internal/proxy"
"github.com/netbirdio/netbird/proxy/internal/restrict"
"github.com/netbirdio/netbird/proxy/internal/types"
)
// appsecEngine is a stub AppSec component returning a fixed remediation.
func appsecEngine(t *testing.T, status int, body string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
if body != "" {
_, _ = w.Write([]byte(body))
}
}))
t.Cleanup(srv.Close)
return srv
}
// serveWithAppSec runs a request through the middleware for a domain configured
// with the given AppSec mode, returning the response and the captured metadata.
func serveWithAppSec(t *testing.T, mode restrict.AppSecMode, client *appsec.Client, r *http.Request) (*httptest.ResponseRecorder, map[string]string, bool) {
t.Helper()
mw := NewMiddleware(log.StandardLogger(), nil, nil)
mw.SetAppSec(client)
require.NoError(t, mw.AddDomain("svc.example.com", DomainSettings{
AccountID: types.AccountID("acct-1"),
ServiceID: types.ServiceID("svc-1"),
AppSecMode: mode,
}))
reached := false
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
}))
cd := proxy.NewCapturedData("req-1")
r = r.WithContext(proxy.WithCapturedData(r.Context(), cd))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, r)
return rec, cd.GetMetadata(), reached
}
func appsecRequest() *http.Request {
r := httptest.NewRequest(http.MethodGet, "http://svc.example.com/?x=/etc/passwd", nil)
r.Host = "svc.example.com"
r.RemoteAddr = "203.0.113.7:44444"
return r
}
func TestCheckAppSec_EnforceBlocksBannedRequest(t *testing.T) {
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban","http_status":403}`)
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
require.NoError(t, err)
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, appsecRequest())
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.False(t, reached, "a banned request must not reach the backend")
assert.Equal(t, "appsec_ban", meta["appsec_verdict"])
assert.NotContains(t, meta, "appsec_mode", "enforce is the default, only observe is annotated")
}
func TestCheckAppSec_ObserveAllowsAndRecordsVerdict(t *testing.T) {
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban","http_status":403}`)
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
require.NoError(t, err)
rec, meta, reached := serveWithAppSec(t, restrict.AppSecObserve, client, appsecRequest())
assert.Equal(t, http.StatusOK, rec.Code)
assert.True(t, reached, "observe mode must not block")
assert.Equal(t, "appsec_ban", meta["appsec_verdict"])
assert.Equal(t, "observe", meta["appsec_mode"])
}
func TestCheckAppSec_AllowedRequestPasses(t *testing.T) {
srv := appsecEngine(t, http.StatusOK, `{"action":"allow","http_status":200}`)
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
require.NoError(t, err)
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, appsecRequest())
assert.Equal(t, http.StatusOK, rec.Code)
assert.True(t, reached)
assert.NotContains(t, meta, "appsec_verdict", "a clean request records no verdict")
}
func TestCheckAppSec_OffSkipsInspection(t *testing.T) {
// An engine that would ban everything; the mode must keep us away from it.
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban"}`)
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
require.NoError(t, err)
rec, meta, reached := serveWithAppSec(t, restrict.AppSecOff, client, appsecRequest())
assert.Equal(t, http.StatusOK, rec.Code)
assert.True(t, reached)
assert.Empty(t, meta)
}
func TestCheckAppSec_EnforceFailsClosedWithoutClient(t *testing.T) {
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, nil, appsecRequest())
assert.Equal(t, http.StatusForbidden, rec.Code,
"enforce with no configured endpoint must deny rather than pass traffic uninspected")
assert.False(t, reached)
assert.Equal(t, "appsec_unavailable", meta["appsec_verdict"])
}
func TestCheckAppSec_ObserveAllowsWithoutClient(t *testing.T) {
rec, meta, reached := serveWithAppSec(t, restrict.AppSecObserve, nil, appsecRequest())
assert.Equal(t, http.StatusOK, rec.Code)
assert.True(t, reached)
assert.Equal(t, "appsec_unavailable", meta["appsec_verdict"])
assert.Equal(t, "observe", meta["appsec_mode"])
}
func TestCheckAppSec_EnforceFailsClosedWhenEngineUnreachable(t *testing.T) {
client, err := appsec.New(appsec.Config{URL: "http://127.0.0.1:1/", APIKey: "k"})
require.NoError(t, err)
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, appsecRequest())
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.False(t, reached)
assert.Equal(t, "appsec_unavailable", meta["appsec_verdict"])
}
func TestCheckAppSec_InspectsOverlayTraffic(t *testing.T) {
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban"}`)
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
require.NoError(t, err)
// Requests arriving over the WireGuard overlay skip the geo and IP-reputation
// checks, but request content is just as inspectable.
r := appsecRequest()
r = r.WithContext(types.WithOverlayOrigin(r.Context()))
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, r)
assert.Equal(t, http.StatusForbidden, rec.Code, "overlay traffic must still be inspected")
assert.False(t, reached)
assert.Equal(t, "appsec_ban", meta["appsec_verdict"])
}
func TestCheckAppSec_UnresolvableClientIPFailsClosed(t *testing.T) {
srv := appsecEngine(t, http.StatusOK, `{"action":"allow"}`)
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
require.NoError(t, err)
r := appsecRequest()
r.RemoteAddr = "not-an-address"
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, r)
assert.Equal(t, http.StatusForbidden, rec.Code,
"the engine requires a client address; a request we cannot attribute must not pass")
assert.False(t, reached)
assert.Equal(t, "appsec_unavailable", meta["appsec_verdict"])
}
// The redaction sets are resolved from the domain's schemes at registration, so
// what AppSec withholds cannot drift from what those schemes actually accept.
func TestAddDomain_ResolvesRedactionSetsFromSchemes(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
require.NoError(t, mw.AddDomain("svc.example.com", DomainSettings{
Schemes: []Scheme{
NewPassword(nil, "svc-1", "acct-1"),
NewHeader(nil, "svc-1", "acct-1", "X-Api-Key"),
},
SessionPublicKey: base64.StdEncoding.EncodeToString(make([]byte, ed25519.PublicKeySize)),
SessionExpiration: time.Hour,
AppSecMode: restrict.AppSecEnforce,
}))
mw.domainsMux.RLock()
config := mw.domains["svc.example.com"]
mw.domainsMux.RUnlock()
assert.Equal(t, []string{"password"}, config.redactBodyFields)
assert.Equal(t, []string{"X-Api-Key"}, config.redactHeaders)
// r.FormValue merges the query into the form, so a credential passed there
// authenticates and must be redacted alongside the OIDC session token.
assert.Equal(t, []string{"session_token", "password"}, config.redactQueryParams)
}
// countingBudget records reservations so a test can observe when the
// middleware hands them back.
type countingBudget struct {
mu sync.Mutex
total int64
used int64
maxAtOnce int64
}
func (b *countingBudget) Acquire(n int64) bool {
b.mu.Lock()
defer b.mu.Unlock()
if b.used+n > b.total {
return false
}
b.used += n
if b.used > b.maxAtOnce {
b.maxAtOnce = b.used
}
return true
}
func (b *countingBudget) Release(n int64) {
b.mu.Lock()
defer b.mu.Unlock()
b.used -= n
}
func (b *countingBudget) inUse() int64 {
b.mu.Lock()
defer b.mu.Unlock()
return b.used
}
// The buffered body stays alive as r.Body until the backend has read it, so
// Protect must hold the reservation for the whole request and return it only
// once the handler chain has unwound. Releasing inside Inspect would let the
// budget admit buffering that is still resident.
func TestProtect_AppSecBudgetHeldForRequestAndReleasedAfter(t *testing.T) {
srv := appsecEngine(t, http.StatusOK, `{"action":"allow"}`)
budget := &countingBudget{total: 1 << 20}
client, err := appsec.New(appsec.Config{
URL: srv.URL,
APIKey: "k",
MaxBodyBytes: 4096,
Budget: budget,
})
require.NoError(t, err)
mw := NewMiddleware(log.StandardLogger(), nil, nil)
mw.SetAppSec(client)
require.NoError(t, mw.AddDomain("svc.example.com", DomainSettings{
AccountID: types.AccountID("acct-1"),
ServiceID: types.ServiceID("svc-1"),
AppSecMode: restrict.AppSecEnforce,
}))
var inHandler int64
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The backend reads the buffered body here, so the reservation must
// still be held at this point.
inHandler = budget.inUse()
_, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
}))
r := httptest.NewRequest(http.MethodPost, "http://svc.example.com/", strings.NewReader("payload"))
r.Host = "svc.example.com"
cd := proxy.NewCapturedData("req-1")
r = r.WithContext(proxy.WithCapturedData(r.Context(), cd))
handler.ServeHTTP(httptest.NewRecorder(), r)
assert.Equal(t, int64(4096), inHandler, "the reservation must be held while the backend reads the body")
assert.Equal(t, int64(0), budget.inUse(), "Protect must release the reservation once the request is served")
}
// A denied request never reaches the backend, but Protect still has to hand the
// reservation back or the pool leaks one cap per blocked request.
func TestProtect_AppSecBudgetReleasedOnDeny(t *testing.T) {
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban"}`)
budget := &countingBudget{total: 1 << 20}
client, err := appsec.New(appsec.Config{
URL: srv.URL,
APIKey: "k",
MaxBodyBytes: 4096,
Budget: budget,
})
require.NoError(t, err)
r := httptest.NewRequest(http.MethodPost, "http://svc.example.com/", strings.NewReader("payload"))
r.Host = "svc.example.com"
rec, _, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, r)
assert.False(t, reached, "a banned request must not reach the backend")
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.Equal(t, int64(0), budget.inUse(), "a blocked request must not leak its reservation")
}

View File

@@ -39,11 +39,6 @@ func (Header) Type() auth.Method {
return auth.MethodHeader
}
// HeaderName returns the request header this scheme reads its credential from.
func (h Header) HeaderName() string {
return h.headerName
}
// Authenticate checks for the configured header in the request. If absent,
// returns empty (unauthenticated). If present, validates via gRPC.
func (h Header) Authenticate(r *http.Request) (string, string, error) {

View File

@@ -18,7 +18,6 @@ import (
"google.golang.org/grpc"
"github.com/netbirdio/netbird/proxy/auth"
"github.com/netbirdio/netbird/proxy/internal/appsec"
"github.com/netbirdio/netbird/proxy/internal/proxy"
"github.com/netbirdio/netbird/proxy/internal/restrict"
"github.com/netbirdio/netbird/proxy/internal/types"
@@ -26,11 +25,6 @@ import (
"github.com/netbirdio/netbird/shared/management/proto"
)
// sessionCookieNames is the cookie set AppSec redacts before mirroring: the
// proxy's session cookie is a bearer credential for the service, and the
// reverse proxy already strips it before forwarding upstream.
var sessionCookieNames = []string{auth.SessionCookieName}
// errValidationUnavailable indicates that session validation failed due to
// an infrastructure error (e.g. gRPC unavailable), not an invalid token.
var errValidationUnavailable = errors.New("session validation unavailable")
@@ -65,14 +59,6 @@ type DomainConfig struct {
IPRestrictions *restrict.Filter
// Private routes the domain through ValidateTunnelPeer; failure → 403.
Private bool
// AppSecMode enables CrowdSec AppSec request inspection for this domain.
AppSecMode restrict.AppSecMode
// redact* name the credentials this domain's schemes accept, resolved once
// at registration. AppSec replaces their values before mirroring a request,
// matching what the reverse proxy strips before forwarding upstream.
redactBodyFields []string
redactHeaders []string
redactQueryParams []string
}
type validationResult struct {
@@ -96,9 +82,6 @@ type Middleware struct {
sessionValidator SessionValidator
geo restrict.GeoResolver
tunnelCache *tunnelValidationCache
// appsec is the shared CrowdSec AppSec client, nil when the proxy has no
// AppSec endpoint configured. Set once during startup, before serving.
appsec *appsec.Client
}
// NewMiddleware creates a new authentication middleware. The sessionValidator is
@@ -116,12 +99,6 @@ func NewMiddleware(logger *log.Logger, sessionValidator SessionValidator, geo re
}
}
// SetAppSec installs the shared CrowdSec AppSec client. Must be called during
// startup, before the middleware serves any request.
func (mw *Middleware) SetAppSec(client *appsec.Client) {
mw.appsec = client
}
// Protect wraps next with per-domain authentication and IP restriction checks.
// Requests whose Host is not registered pass through unchanged.
func (mw *Middleware) Protect(next http.Handler) http.Handler {
@@ -146,14 +123,6 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
return
}
// Deferred, not released here: the inspected body stays alive as r.Body
// until the backend has read it, which happens inside next.ServeHTTP.
appSecAllowed, releaseAppSec := mw.checkAppSec(w, r, config)
defer releaseAppSec()
if !appSecAllowed {
return
}
// Private services bypass operator schemes and gate on tunnel peer.
if config.Private {
if mw.forwardWithTunnelPeer(w, r, host, config, next) {
@@ -293,134 +262,6 @@ func (mw *Middleware) checkIPRestrictions(w http.ResponseWriter, r *http.Request
return false
}
// checkAppSec mirrors the request to the CrowdSec AppSec engine when the domain
// enables inspection. Returns false when the request was blocked and a response
// has been written.
//
// The returned release frees the body-buffering budget the inspection reserved
// and is never nil. It must run only after the request has been served, since
// the buffered body stays alive as r.Body for the backend to read.
//
// Every non-allow remediation blocks with 403, captcha included: the proxy has
// no challenge flow to serve. The distinct verdict is still recorded so the
// access log shows which remediation the engine actually chose.
//
// Unlike the geo and IP-reputation checks, this runs for overlay traffic too:
// AppSec inspects request content, which is just as meaningful when the caller
// reached the proxy through the WireGuard tunnel.
func (mw *Middleware) checkAppSec(w http.ResponseWriter, r *http.Request, config DomainConfig) (bool, func()) {
if !config.AppSecMode.Enabled() {
return true, func() {}
}
verdict, release := mw.inspectAppSec(r, config)
if verdict == restrict.Allow {
return true, release
}
observe := config.AppSecMode == restrict.AppSecObserve
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetMetadata("appsec_verdict", verdict.String())
if observe {
cd.SetMetadata("appsec_mode", "observe")
}
}
if observe {
mw.logger.Debugf("AppSec observe: would block %s for %s (%s)", r.RemoteAddr, r.Host, verdict)
return true, release
}
mw.markDenied(r, verdict.String())
mw.logger.Debugf("AppSec: %s for %s %s", verdict, r.Host, r.RemoteAddr)
http.Error(w, "Forbidden", http.StatusForbidden)
return false, release
}
// inspectAppSec runs the AppSec call and returns its verdict. Failures come
// back as DenyAppSecUnavailable regardless of mode so observe mode still
// records that inspection did not happen; the caller decides what blocks. The
// returned release is never nil.
func (mw *Middleware) inspectAppSec(r *http.Request, config DomainConfig) (restrict.Verdict, func()) {
// Mode requested but the proxy has no AppSec endpoint configured. Management
// gates this on the cluster capability; a stale mapping can still arrive.
if mw.appsec == nil {
mw.logger.Debugf("AppSec mode %q requested for %s but no AppSec endpoint is configured", config.AppSecMode, r.Host)
return restrict.DenyAppSecUnavailable, func() {}
}
clientIP := mw.resolveClientIP(r)
if !clientIP.IsValid() {
// The engine requires a client address, and a request whose source we
// cannot establish is exactly the kind we must not wave through.
mw.logger.Debugf("AppSec: cannot resolve client address for %q", r.RemoteAddr)
return restrict.DenyAppSecUnavailable, func() {}
}
req := appsec.Request{
HTTP: r,
ClientIP: clientIP,
RedactBodyFields: config.redactBodyFields,
RedactHeaders: config.redactHeaders,
RedactCookies: sessionCookieNames,
RedactQueryParams: config.redactQueryParams,
}
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
req.TransactionID = cd.GetRequestID()
}
result, err := mw.appsec.Inspect(r.Context(), req)
if err != nil {
mw.logger.Debugf("AppSec inspection failed for %s: %v", r.Host, err)
}
// Record when the body went uninspected: headers and URI were still
// checked, but an operator reading the log should not read a clean verdict
// as "the payload was examined". Oversize is reachable by padding, so its
// absence from the log would hide a deliberate opt-out.
if result.BodyBypass != "" {
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetMetadata("appsec_body_bypass", result.BodyBypass)
}
}
return result.Verdict, result.Release
}
// credentialFormFields lists the login form fields whose values are redacted
// from the mirrored body, so a password or PIN submitted to the proxy's own
// login form never reaches the Security Engine.
func credentialFormFields(schemes []Scheme) []string {
var fields []string
for _, s := range schemes {
switch s.Type() {
case auth.MethodPassword:
fields = append(fields, passwordFormId)
case auth.MethodPIN:
fields = append(fields, pinFormId)
}
}
return fields
}
// credentialHeaders lists the request headers whose values are redacted from
// the mirrored request. A header-auth scheme carries a session token the proxy
// validates and never forwards upstream, so the engine must not see it either.
func credentialHeaders(schemes []Scheme) []string {
var names []string
for _, s := range schemes {
// Structural, not a concrete Header assertion: if the scheme is ever
// registered as a pointer, a type assertion would quietly stop matching
// and the header would start reaching the engine again.
named, ok := s.(interface{ HeaderName() string })
if !ok {
continue
}
if name := named.HeaderName(); name != "" {
names = append(names, name)
}
}
return names
}
// resolveClientIP extracts the real client IP from CapturedData, falling back to r.RemoteAddr.
func (mw *Middleware) resolveClientIP(r *http.Request) netip.Addr {
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
@@ -440,18 +281,12 @@ func (mw *Middleware) resolveClientIP(r *http.Request) netip.Addr {
return addr.Unmap()
}
// markDenied records the deny reason on the captured data so the access log
// attributes the response to the proxy rather than the backend.
func (mw *Middleware) markDenied(r *http.Request, reason string) {
// blockIPRestriction sets captured data fields for an IP-restriction block event.
func (mw *Middleware) blockIPRestriction(r *http.Request, reason string) {
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetOrigin(proxy.OriginAuth)
cd.SetAuthMethod(reason)
}
}
// blockIPRestriction sets captured data fields for an IP-restriction block event.
func (mw *Middleware) blockIPRestriction(r *http.Request, reason string) {
mw.markDenied(r, reason)
mw.logger.Debugf("IP restriction: %s for %s", reason, r.RemoteAddr)
}
@@ -802,61 +637,45 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool {
case auth.MethodPassword:
return r.FormValue("password") != ""
case auth.MethodOIDC:
return r.URL.Query().Get(sessionTokenParam) != ""
return r.URL.Query().Get("session_token") != ""
}
return false
}
// DomainSettings is the per-domain configuration AddDomain applies.
type DomainSettings struct {
Schemes []Scheme
// SessionPublicKey is the base64-encoded ed25519 key used to verify session
// cookies. Required when Schemes is non-empty.
SessionPublicKey string
SessionExpiration time.Duration
AccountID types.AccountID
ServiceID types.ServiceID
IPRestrictions *restrict.Filter
// Private forces ValidateTunnelPeer enforcement (403 on failure) regardless
// of the schemes list.
Private bool
AppSecMode restrict.AppSecMode
}
// AddDomain registers authentication schemes for the given domain. With schemes
// a valid session public key is required.
func (mw *Middleware) AddDomain(domain string, settings DomainSettings) error {
credentialFields := credentialFormFields(settings.Schemes)
config := DomainConfig{
AccountID: settings.AccountID,
ServiceID: settings.ServiceID,
IPRestrictions: settings.IPRestrictions,
Private: settings.Private,
AppSecMode: settings.AppSecMode,
redactBodyFields: credentialFields,
redactHeaders: credentialHeaders(settings.Schemes),
// A credential can arrive in the query too: r.FormValue merges the URL
// query into the form, so "?password=..." authenticates just as a form
// post does and must not be mirrored in the clear either.
redactQueryParams: append([]string{sessionTokenParam}, credentialFields...),
// AddDomain registers authentication schemes for the given domain. With schemes a valid session public key is required.
// private=true forces ValidateTunnelPeer enforcement (403 on failure) regardless of the schemes list.
func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool) error {
if len(schemes) == 0 {
mw.domainsMux.Lock()
defer mw.domainsMux.Unlock()
mw.domains[domain] = DomainConfig{
AccountID: accountID,
ServiceID: serviceID,
IPRestrictions: ipRestrictions,
Private: private,
}
return nil
}
if len(settings.Schemes) > 0 {
pubKeyBytes, err := base64.StdEncoding.DecodeString(settings.SessionPublicKey)
if err != nil {
return fmt.Errorf("decode session public key for domain %s: %w", domain, err)
}
if len(pubKeyBytes) != ed25519.PublicKeySize {
return fmt.Errorf("invalid session public key size for domain %s: got %d, want %d", domain, len(pubKeyBytes), ed25519.PublicKeySize)
}
config.Schemes = settings.Schemes
config.SessionPublicKey = pubKeyBytes
config.SessionExpiration = settings.SessionExpiration
pubKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64)
if err != nil {
return fmt.Errorf("decode session public key for domain %s: %w", domain, err)
}
if len(pubKeyBytes) != ed25519.PublicKeySize {
return fmt.Errorf("invalid session public key size for domain %s: got %d, want %d", domain, len(pubKeyBytes), ed25519.PublicKeySize)
}
mw.domainsMux.Lock()
defer mw.domainsMux.Unlock()
mw.domains[domain] = config
mw.domains[domain] = DomainConfig{
Schemes: schemes,
SessionPublicKey: pubKeyBytes,
SessionExpiration: expiration,
AccountID: accountID,
ServiceID: serviceID,
IPRestrictions: ipRestrictions,
Private: private,
}
return nil
}
@@ -911,10 +730,10 @@ func (mw *Middleware) validateSessionToken(ctx context.Context, host, token stri
// parameter removed so it doesn't linger in the browser's address bar or history.
func stripSessionTokenParam(u *url.URL) string {
q := u.Query()
if !q.Has(sessionTokenParam) {
if !q.Has("session_token") {
return u.RequestURI()
}
q.Del(sessionTokenParam)
q.Del("session_token")
clean := *u
clean.RawQuery = q.Encode()
return clean.RequestURI()

View File

@@ -64,7 +64,7 @@ func TestAddDomain_ValidKey(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour})
err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)
require.NoError(t, err)
mw.domainsMux.RLock()
@@ -81,7 +81,7 @@ func TestAddDomain_EmptyKey(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionExpiration: time.Hour})
err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid session public key size")
@@ -95,7 +95,7 @@ func TestAddDomain_InvalidBase64(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: "not-valid-base64!!!", SessionExpiration: time.Hour})
err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "decode session public key")
@@ -110,7 +110,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) {
shortKey := base64.StdEncoding.EncodeToString([]byte("tooshort"))
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: shortKey, SessionExpiration: time.Hour})
err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid session public key size")
@@ -123,7 +123,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) {
func TestAddDomain_NoSchemes_NoKeyRequired(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", DomainSettings{SessionExpiration: time.Hour})
err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false)
require.NoError(t, err, "domains with no auth schemes should not require a key")
mw.domainsMux.RLock()
@@ -139,8 +139,8 @@ func TestAddDomain_OverwritesPreviousConfig(t *testing.T) {
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp1.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp2.PublicKey, SessionExpiration: 2 * time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil, false))
mw.domainsMux.RLock()
config := mw.domains["example.com"]
@@ -156,7 +156,7 @@ func TestRemoveDomain(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
mw.RemoveDomain("example.com")
@@ -180,7 +180,7 @@ func TestProtect_UnknownDomainPassesThrough(t *testing.T) {
func TestProtect_DomainWithNoSchemesPassesThrough(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
require.NoError(t, mw.AddDomain("example.com", DomainSettings{SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -197,7 +197,7 @@ func TestProtect_UnauthenticatedRequestIsBlocked(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -218,7 +218,7 @@ func TestProtect_HostWithPortIsMatched(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -239,7 +239,7 @@ func TestProtect_ValidSessionCookiePassesThrough(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
require.NoError(t, err)
@@ -272,7 +272,7 @@ func TestProtect_SessionCookieGroupsPropagate(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
groups := []string{"engineering", "sre"}
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, groups, nil, time.Hour)
@@ -337,7 +337,7 @@ func TestProtect_PrivateService_TunnelPeerGroupsPropagate(t *testing.T) {
kp := generateTestKeyPair(t)
// Private service: no operator schemes — auth gates solely on the tunnel peer.
require.NoError(t, mw.AddDomain("agent.example.com", DomainSettings{SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acct-1", ServiceID: "svc-1", Private: true}))
require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true))
cd := proxy.NewCapturedData("")
cd.SetClientIP(netip.MustParseAddr("100.90.1.14")) // CGNAT tunnel source
@@ -377,7 +377,7 @@ func TestProtect_PrivateService_TunnelPeerDenied(t *testing.T) {
}}
mw := NewMiddleware(log.StandardLogger(), validator, nil)
kp := generateTestKeyPair(t)
require.NoError(t, mw.AddDomain("agent.example.com", DomainSettings{SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acct-1", ServiceID: "svc-1", Private: true}))
require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true))
cd := proxy.NewCapturedData("")
cd.SetClientIP(netip.MustParseAddr("100.90.1.14"))
@@ -405,7 +405,7 @@ func TestProtect_ExpiredSessionCookieIsRejected(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
// Sign a token that expired 1 second ago.
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, -time.Second)
@@ -431,7 +431,7 @@ func TestProtect_WrongDomainCookieIsRejected(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
// Token signed for a different domain audience.
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "other.com", auth.MethodPIN, nil, nil, time.Hour)
@@ -458,7 +458,7 @@ func TestProtect_WrongKeyCookieIsRejected(t *testing.T) {
kp2 := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp1.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false))
// Token signed with a different private key.
token, err := sessionkey.SignToken(kp2.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
@@ -495,7 +495,7 @@ func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) {
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -548,7 +548,7 @@ func TestProtect_FailedAuthDoesNotSetCookie(t *testing.T) {
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -584,7 +584,7 @@ func TestProtect_MultipleSchemes(t *testing.T) {
return "", "password", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{pinScheme, passwordScheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -614,7 +614,7 @@ func TestProtect_InvalidTokenFromSchemeReturns400(t *testing.T) {
return "invalid-jwt-token", "", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -638,7 +638,7 @@ func TestAddDomain_RandomBytes32NotEd25519(t *testing.T) {
key := base64.StdEncoding.EncodeToString(randomBytes)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
err = mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: key, SessionExpiration: time.Hour})
err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil, false)
require.NoError(t, err, "any 32-byte key should be accepted at registration time")
}
@@ -647,10 +647,10 @@ func TestAddDomain_InvalidKeyDoesNotCorruptExistingConfig(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
// Attempt to overwrite with an invalid key.
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: "bad", SessionExpiration: time.Hour})
err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil, false)
require.Error(t, err)
// The original valid config should still be intact.
@@ -674,7 +674,7 @@ func TestProtect_FailedPinAuthCapturesAuthMethod(t *testing.T) {
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -701,7 +701,7 @@ func TestProtect_FailedPasswordAuthCapturesAuthMethod(t *testing.T) {
return "", "password", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -728,7 +728,7 @@ func TestProtect_NoCredentialsDoesNotCaptureAuthMethod(t *testing.T) {
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -815,7 +815,8 @@ func TestWasCredentialSubmitted(t *testing.T) {
func TestCheckIPRestrictions_UnparseableAddress(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}})})
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}), false)
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -850,7 +851,8 @@ func TestCheckIPRestrictions_UsesCapturedDataClientIP(t *testing.T) {
// trusted proxies), checkIPRestrictions should use that IP, not RemoteAddr.
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}})})
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}}), false)
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -890,7 +892,8 @@ func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) {
// Geo is nil, country restrictions are configured: must deny (fail-close).
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}})})
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}}), false)
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -913,10 +916,11 @@ func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) {
func TestCheckIPRestrictions_OverlayOriginSkipsCountryRules(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{
AllowedCIDRs: []string{"100.64.0.0/10"},
AllowedCountries: []string{"US"},
})})
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
restrict.ParseFilter(restrict.FilterConfig{
AllowedCIDRs: []string{"100.64.0.0/10"},
AllowedCountries: []string{"US"},
}), false)
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -949,7 +953,8 @@ func TestCheckIPRestrictions_OverlayOriginSkipsCountryRules(t *testing.T) {
func TestCheckIPRestrictions_OverlayOriginRespectsCIDR(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}})})
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}}), false)
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -977,7 +982,7 @@ func TestProtect_OIDCOnlyRedirectsDirectly(t *testing.T) {
return "", oidcURL, nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -1006,7 +1011,7 @@ func TestProtect_OIDCWithOtherMethodShowsLoginPage(t *testing.T) {
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{oidcScheme, pinScheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{oidcScheme, pinScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -1050,7 +1055,7 @@ func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) {
kp := generateTestKeyPair(t)
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
var backendCalled bool
capturedData := proxy.NewCapturedData("")
@@ -1093,7 +1098,7 @@ func TestProtect_HeaderAuth_MissingHeaderFallsThrough(t *testing.T) {
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
// Also add a PIN scheme so we can verify fallthrough behavior.
pinScheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr, pinScheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -1113,7 +1118,7 @@ func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) {
return &proto.AuthenticateResponse{Success: false}, nil
}}
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -1136,7 +1141,7 @@ func TestProtect_HeaderAuth_InfraErrorReturns502(t *testing.T) {
return nil, errors.New("gRPC unavailable")
}}
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -1153,7 +1158,7 @@ func TestProtect_HeaderAuth_SubsequentRequestUsesSessionCookie(t *testing.T) {
kp := generateTestKeyPair(t)
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -1213,7 +1218,7 @@ func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) {
// Single Header scheme (as if one entry existed), but the mock checks both values.
hdr := NewHeader(mock, "svc1", "acc1", "Authorization")
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
var backendCalled bool
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -1271,7 +1276,7 @@ func TestProtect_OIDCOnPlainHTTP_BlockedWith400(t *testing.T) {
return "", "https://idp.example.com/authorize", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -1295,7 +1300,7 @@ func TestProtect_OIDCOverTLS_NotBlocked(t *testing.T) {
return "", "https://idp.example.com/authorize", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -1315,7 +1320,7 @@ func TestProtect_NonOIDCSchemes_PlainHTTP_NotBlocked(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -1345,7 +1350,7 @@ func TestProtect_TunnelPeerFastPath_RequiresInboundMarker(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -1380,7 +1385,7 @@ func TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())

View File

@@ -13,10 +13,6 @@ import (
"github.com/netbirdio/netbird/shared/management/proto"
)
// sessionTokenParam is the query parameter the management server uses to hand
// the minted session token back to the proxy after an OIDC login.
const sessionTokenParam = "session_token"
type urlGenerator interface {
GetOIDCURL(context.Context, *proto.GetOIDCURLRequest, ...grpc.CallOption) (*proto.GetOIDCURLResponse, error)
}
@@ -47,7 +43,7 @@ func (o OIDC) Authenticate(r *http.Request) (string, string, error) {
// Check for the session_token query param (from OIDC redirects).
// The management server passes the token in the URL because it cannot set
// cookies for the proxy's domain (cookies are domain-scoped per RFC 6265).
if token := r.URL.Query().Get(sessionTokenParam); token != "" {
if token := r.URL.Query().Get("session_token"); token != "" {
return token, "", nil
}

View File

@@ -44,7 +44,7 @@ func (s *stubSessionValidator) ValidateTunnelPeer(_ context.Context, in *proto.V
func newTunnelMiddleware(t *testing.T, validator SessionValidator) *Middleware {
t.Helper()
mw := NewMiddleware(log.New(), validator, nil)
require.NoError(t, mw.AddDomain("svc.example", DomainSettings{AccountID: "acct-1", ServiceID: "svc-1"}))
require.NoError(t, mw.AddDomain("svc.example", nil, "", 0, "acct-1", "svc-1", nil, false))
return mw
}
@@ -235,8 +235,8 @@ func TestForwardWithTunnelPeer_RoutesAccountIDIntoCacheKey(t *testing.T) {
}
mw := NewMiddleware(log.New(), validator, nil)
require.NoError(t, mw.AddDomain("svc-a.example", DomainSettings{AccountID: "acct-a", ServiceID: "svc-a"}))
require.NoError(t, mw.AddDomain("svc-b.example", DomainSettings{AccountID: "acct-b", ServiceID: "svc-b"}))
require.NoError(t, mw.AddDomain("svc-a.example", nil, "", 0, "acct-a", "svc-a", nil, false))
require.NoError(t, mw.AddDomain("svc-b.example", nil, "", 0, "acct-b", "svc-b", nil, false))
// The fast-path requires the inbound-listener marker on the context.
// The peerstore lookup itself is account-agnostic at this level
@@ -299,7 +299,7 @@ func TestForwardWithTunnelPeer_LocalLookupShortCircuitDoesNotPopulateCache(t *te
func TestPrivateService_FailsClosedOnTunnelPeerFailure(t *testing.T) {
mw := NewMiddleware(log.New(), nil, nil)
require.NoError(t, mw.AddDomain("private.svc", DomainSettings{AccountID: "acct-1", ServiceID: "svc-1", Private: true}))
require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true))
called := false
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -328,7 +328,7 @@ func TestPrivateService_ForwardsOnTunnelPeerSuccess(t *testing.T) {
},
}
mw := NewMiddleware(log.New(), validator, nil)
require.NoError(t, mw.AddDomain("private.svc", DomainSettings{AccountID: "acct-1", ServiceID: "svc-1", Private: true}))
require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true))
called := false
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {

View File

@@ -28,12 +28,12 @@ func TestDefaultTable_FirstPartyModelCoverage(t *testing.T) {
"ministral-8b-latest", "mistral-embed",
},
"anthropic": {
"claude-fable-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6",
"claude-fable-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6",
"claude-opus-4-1", "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-haiku-4-5",
},
// bedrock keys are the normalized ids the request parser emits.
"bedrock": {
"anthropic.claude-opus-4-8", "anthropic.claude-opus-4-7", "anthropic.claude-opus-4-6",
"anthropic.claude-opus-5", "anthropic.claude-opus-4-8", "anthropic.claude-opus-4-7", "anthropic.claude-opus-4-6",
"anthropic.claude-opus-4-1", "anthropic.claude-sonnet-4-6", "anthropic.claude-sonnet-4-5",
"anthropic.claude-haiku-4-5", "meta.llama3-3-70b-instruct",
"amazon.nova-pro", "amazon.nova-lite", "amazon.nova-micro", "amazon.nova-2-lite",

View File

@@ -180,6 +180,11 @@ anthropic:
output_per_1k: 0.050
cache_read_per_1k: 0.001
cache_creation_per_1k: 0.0125
claude-opus-5:
input_per_1k: 0.005
output_per_1k: 0.025
cache_read_per_1k: 0.0005
cache_creation_per_1k: 0.00625
claude-opus-4-8:
input_per_1k: 0.005
output_per_1k: 0.025
@@ -236,6 +241,11 @@ bedrock:
# eu.anthropic.claude-sonnet-4-5-20250929-v1:0 -> anthropic.claude-sonnet-4-5.
# Anthropic-on-Bedrock keeps the additive cache buckets (read ≈0.1x input,
# write ≈1.25x input); Nova / Llama report no cache, so cost is input+output.
anthropic.claude-opus-5:
input_per_1k: 0.005
output_per_1k: 0.025
cache_read_per_1k: 0.0005
cache_creation_per_1k: 0.00625
anthropic.claude-opus-4-8:
input_per_1k: 0.005
output_per_1k: 0.025

View File

@@ -21,8 +21,6 @@ import (
"strconv"
"strings"
"sync"
"github.com/netbirdio/netbird/proxy/internal/netutil"
)
// MaxRoutingScanBytes bounds how far ScanRoutingFields will read into a
@@ -36,6 +34,7 @@ const MaxRoutingScanBytes int64 = 32 << 20
// metadata key by the chain when a request body is not surfaced.
const (
BypassUpgradeHeader = "upgrade_header"
BypassConnectionUpgrd = "connection_upgrade"
BypassContentType = "content_type_not_allowed"
BypassBudget = "capture_budget_exhausted"
BypassNoConfig = "no_capture_config"
@@ -126,13 +125,12 @@ func CaptureRequest(r *http.Request, cfg *Config, b Budget) (body []byte, trunca
if cfg.MaxRequestBytes <= 0 {
return nil, false, 0, BypassCapZero, release, nil
}
// The predicate has to be the forwarder's own: a looser one (either header
// on its own) skips capture for requests the forwarder still delivers to
// the upstream with their body intact, which hides them from every
// deny-capable middleware in the chain.
if netutil.IsUpgradeRequest(r.Header) {
if r.Header.Get("Upgrade") != "" {
return nil, false, 0, BypassUpgradeHeader, release, nil
}
if strings.EqualFold(r.Header.Get("Connection"), "upgrade") {
return nil, false, 0, BypassConnectionUpgrd, release, nil
}
if !contentTypeAllowed(r.Header.Get("Content-Type"), cfg.ContentTypes) {
return nil, false, 0, BypassContentType, release, nil
}

View File

@@ -1,23 +0,0 @@
package netutil
import (
"net/http"
"golang.org/x/net/http/httpguts"
)
// IsUpgradeRequest reports whether r is a protocol-upgrade request, using the
// same predicate httputil.ReverseProxy applies when it decides to hand the
// connection over instead of proxying normally.
//
// Matching the forwarder exactly matters for anything that inspects a request
// before it is proxied: a looser test (an Upgrade header on its own, say) marks
// a request as an upgrade and skips inspection, while the forwarder still
// delivers it to the backend as an ordinary request with its body intact. That
// gap is a body-inspection bypass reachable by adding one header.
func IsUpgradeRequest(h http.Header) bool {
if !httpguts.HeaderValuesContainsToken(h["Connection"], "Upgrade") {
return false
}
return h.Get("Upgrade") != ""
}

View File

@@ -50,37 +50,6 @@ const (
CrowdSecObserve CrowdSecMode = "observe"
)
// AppSecMode is the per-service CrowdSec AppSec (WAF) enforcement mode.
type AppSecMode string
const (
// AppSecOff disables request inspection.
AppSecOff AppSecMode = ""
// AppSecEnforce blocks requests the engine flags, and fails closed when the
// engine is unreachable.
AppSecEnforce AppSecMode = "enforce"
// AppSecObserve records the verdict without blocking.
AppSecObserve AppSecMode = "observe"
)
// ParseAppSecMode maps a wire value to an AppSecMode. Unrecognized values map
// to AppSecOff so a typo never turns inspection into an unintended block.
func ParseAppSecMode(s string) AppSecMode {
switch AppSecMode(s) {
case AppSecEnforce:
return AppSecEnforce
case AppSecObserve:
return AppSecObserve
default:
return AppSecOff
}
}
// Enabled reports whether the mode asks for request inspection.
func (m AppSecMode) Enabled() bool {
return m == AppSecEnforce || m == AppSecObserve
}
// Filter evaluates IP restrictions. CIDR checks are performed first
// (cheap), followed by country lookups (more expensive) only when needed.
type Filter struct {
@@ -177,13 +146,6 @@ const (
// DenyCrowdSecUnavailable indicates enforce mode but the bouncer has not
// completed its initial sync.
DenyCrowdSecUnavailable
// DenyAppSecBan indicates a CrowdSec AppSec "ban" remediation.
DenyAppSecBan
// DenyAppSecCaptcha indicates a CrowdSec AppSec "captcha" remediation.
DenyAppSecCaptcha
// DenyAppSecUnavailable indicates enforce mode but the AppSec engine could
// not produce a verdict (unreachable, timed out, or it rejected the call).
DenyAppSecUnavailable
)
// String returns the deny reason string matching the HTTP auth mechanism names.
@@ -205,12 +167,6 @@ func (v Verdict) String() string {
return "crowdsec_throttle"
case DenyCrowdSecUnavailable:
return "crowdsec_unavailable"
case DenyAppSecBan:
return "appsec_ban"
case DenyAppSecCaptcha:
return "appsec_captcha"
case DenyAppSecUnavailable:
return "appsec_unavailable"
default:
return "unknown"
}
@@ -226,16 +182,6 @@ func (v Verdict) IsCrowdSec() bool {
}
}
// IsAppSec returns true when the verdict originates from an AppSec inspection.
func (v Verdict) IsAppSec() bool {
switch v {
case DenyAppSecBan, DenyAppSecCaptcha, DenyAppSecUnavailable:
return true
default:
return false
}
}
// IsObserveOnly returns true when v is a CrowdSec verdict and the filter is in
// observe mode. Callers should log the verdict but not block the request.
func (f *Filter) IsObserveOnly(v Verdict) bool {

View File

@@ -126,15 +126,6 @@ type Config struct {
// CrowdSecAPIKey is the CrowdSec bouncer API key. Empty disables
// CrowdSec.
CrowdSecAPIKey string
// CrowdSecAppSecURL is the CrowdSec AppSec (WAF) endpoint. Empty disables
// HTTP request inspection.
CrowdSecAppSecURL string
// CrowdSecAppSecTimeout bounds a single AppSec inspection call. Zero falls
// back to the internal default.
CrowdSecAppSecTimeout time.Duration
// CrowdSecAppSecMaxBodyBytes caps the request body mirrored to AppSec.
// Zero falls back to the internal default; negative forwards no body.
CrowdSecAppSecMaxBodyBytes int64
}
// New builds a Server from cfg without performing any I/O. No goroutines
@@ -144,45 +135,42 @@ type Config struct {
// directly) byte-for-byte equivalent.
func New(ctx context.Context, cfg Config) *Server {
return &Server{
ctx: ctx,
ListenAddr: cfg.ListenAddr,
ID: cfg.ID,
Logger: cfg.Logger,
Version: cfg.Version,
ProxyURL: cfg.ProxyURL,
ManagementAddress: cfg.ManagementAddress,
ProxyToken: cfg.ProxyToken,
CertificateDirectory: cfg.CertificateDirectory,
CertificateFile: cfg.CertificateFile,
CertificateKeyFile: cfg.CertificateKeyFile,
GenerateACMECertificates: cfg.GenerateACMECertificates,
ACMEChallengeAddress: cfg.ACMEChallengeAddress,
ACMEDirectory: cfg.ACMEDirectory,
ACMEEABKID: cfg.ACMEEABKID,
ACMEEABHMACKey: cfg.ACMEEABHMACKey,
ACMEChallengeType: cfg.ACMEChallengeType,
CertLockMethod: cfg.CertLockMethod,
WildcardCertDir: cfg.WildcardCertDir,
DebugEndpointEnabled: cfg.DebugEndpointEnabled,
DebugEndpointAddress: cfg.DebugEndpointAddress,
HealthAddress: cfg.HealthAddr,
ForwardedProto: cfg.ForwardedProto,
TrustedProxies: cfg.TrustedProxies,
WireguardPort: cfg.WireguardPort,
ProxyProtocol: cfg.ProxyProtocol,
PreSharedKey: cfg.PreSharedKey,
Performance: cfg.Performance,
SupportsCustomPorts: cfg.SupportsCustomPorts,
RequireSubdomain: cfg.RequireSubdomain,
Private: cfg.Private,
MaxDialTimeout: cfg.MaxDialTimeout,
MaxSessionIdleTimeout: cfg.MaxSessionIdleTimeout,
MappingBatchWatchdog: cfg.MappingBatchWatchdog,
GeoDataDir: cfg.GeoDataDir,
CrowdSecAPIURL: cfg.CrowdSecAPIURL,
CrowdSecAPIKey: cfg.CrowdSecAPIKey,
CrowdSecAppSecURL: cfg.CrowdSecAppSecURL,
CrowdSecAppSecTimeout: cfg.CrowdSecAppSecTimeout,
CrowdSecAppSecMaxBodyBytes: cfg.CrowdSecAppSecMaxBodyBytes,
ctx: ctx,
ListenAddr: cfg.ListenAddr,
ID: cfg.ID,
Logger: cfg.Logger,
Version: cfg.Version,
ProxyURL: cfg.ProxyURL,
ManagementAddress: cfg.ManagementAddress,
ProxyToken: cfg.ProxyToken,
CertificateDirectory: cfg.CertificateDirectory,
CertificateFile: cfg.CertificateFile,
CertificateKeyFile: cfg.CertificateKeyFile,
GenerateACMECertificates: cfg.GenerateACMECertificates,
ACMEChallengeAddress: cfg.ACMEChallengeAddress,
ACMEDirectory: cfg.ACMEDirectory,
ACMEEABKID: cfg.ACMEEABKID,
ACMEEABHMACKey: cfg.ACMEEABHMACKey,
ACMEChallengeType: cfg.ACMEChallengeType,
CertLockMethod: cfg.CertLockMethod,
WildcardCertDir: cfg.WildcardCertDir,
DebugEndpointEnabled: cfg.DebugEndpointEnabled,
DebugEndpointAddress: cfg.DebugEndpointAddress,
HealthAddress: cfg.HealthAddr,
ForwardedProto: cfg.ForwardedProto,
TrustedProxies: cfg.TrustedProxies,
WireguardPort: cfg.WireguardPort,
ProxyProtocol: cfg.ProxyProtocol,
PreSharedKey: cfg.PreSharedKey,
Performance: cfg.Performance,
SupportsCustomPorts: cfg.SupportsCustomPorts,
RequireSubdomain: cfg.RequireSubdomain,
Private: cfg.Private,
MaxDialTimeout: cfg.MaxDialTimeout,
MaxSessionIdleTimeout: cfg.MaxSessionIdleTimeout,
MappingBatchWatchdog: cfg.MappingBatchWatchdog,
GeoDataDir: cfg.GeoDataDir,
CrowdSecAPIURL: cfg.CrowdSecAPIURL,
CrowdSecAPIKey: cfg.CrowdSecAPIKey,
}
}

View File

@@ -240,10 +240,6 @@ func (m *testProxyManager) ClusterSupportsCrowdSec(_ context.Context, _ string)
return nil
}
func (m *testProxyManager) ClusterSupportsAppSec(_ context.Context, _ string) *bool {
return nil
}
func (m *testProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool {
return nil
}
@@ -566,11 +562,16 @@ func TestIntegration_ProxyConnection_ReconnectDoesNotDuplicateState(t *testing.T
addMappingCalls.Add(1)
// Apply to real auth middleware (idempotent)
err := authMw.AddDomain(mapping.GetDomain(), auth.DomainSettings{
AccountID: proxytypes.AccountID(mapping.GetAccountId()),
ServiceID: proxytypes.ServiceID(mapping.GetId()),
Private: mapping.GetPrivate(),
})
err := authMw.AddDomain(
mapping.GetDomain(),
nil,
"",
0,
proxytypes.AccountID(mapping.GetAccountId()),
proxytypes.ServiceID(mapping.GetId()),
nil,
mapping.GetPrivate(),
)
require.NoError(t, err)
// Apply to real proxy (idempotent)

View File

@@ -45,7 +45,6 @@ import (
"github.com/netbirdio/netbird/client/embed"
"github.com/netbirdio/netbird/proxy/internal/accesslog"
"github.com/netbirdio/netbird/proxy/internal/acme"
"github.com/netbirdio/netbird/proxy/internal/appsec"
"github.com/netbirdio/netbird/proxy/internal/auth"
"github.com/netbirdio/netbird/proxy/internal/certwatch"
"github.com/netbirdio/netbird/proxy/internal/conntrack"
@@ -127,10 +126,6 @@ type Server struct {
crowdsecMu sync.Mutex
crowdsecServices map[types.ServiceID]bool
// appsecClient is the shared CrowdSec AppSec client, nil when no AppSec
// endpoint is configured. Stateless, so it needs no per-service lifecycle.
appsecClient *appsec.Client
// routerReady is closed once mainRouter is fully initialized.
// The mapping worker waits on this before processing updates.
routerReady chan struct{}
@@ -243,17 +238,6 @@ type Server struct {
CrowdSecAPIURL string
// CrowdSecAPIKey is the CrowdSec bouncer API key. Empty disables CrowdSec.
CrowdSecAPIKey string
// CrowdSecAppSecURL is the CrowdSec AppSec (WAF) endpoint, e.g.
// http://127.0.0.1:7422/. Empty disables request inspection. Requires
// CrowdSecAPIKey, which the AppSec component validates against LAPI.
CrowdSecAppSecURL string
// CrowdSecAppSecTimeout bounds a single AppSec inspection call.
// Zero means appsec.DefaultTimeout.
CrowdSecAppSecTimeout time.Duration
// CrowdSecAppSecMaxBodyBytes caps the request body mirrored to the AppSec
// engine. Zero means appsec.DefaultMaxBodyBytes; negative disables body
// forwarding, leaving header and URI inspection.
CrowdSecAppSecMaxBodyBytes int64
// MaxSessionIdleTimeout caps the per-service session idle timeout.
// Zero means no cap (the proxy honors whatever management sends).
// Set via NB_PROXY_MAX_SESSION_IDLE_TIMEOUT for shared deployments.
@@ -400,13 +384,6 @@ func (s *Server) Start(ctx context.Context) error {
s.crowdsecRegistry = crowdsec.NewRegistry(s.CrowdSecAPIURL, s.CrowdSecAPIKey, log.NewEntry(s.Logger))
s.crowdsecServices = make(map[types.ServiceID]bool)
// Must precede the mapping worker: the worker opens the management stream
// and reports proxyCapabilities, which reads appsecClient. Building it
// afterwards would both race the read and, when the worker won, advertise
// the proxy as AppSec-incapable for the lifetime of that stream.
if err := s.initAppSec(); err != nil {
return err
}
go s.newManagementMappingWorker(runCtx, s.mgmtClient)
@@ -434,7 +411,6 @@ func (s *Server) Start(ctx context.Context) error {
}()
s.auth = auth.NewMiddleware(s.Logger, s.mgmtClient, s.geo)
s.auth.SetAppSec(s.appsecClient)
s.accessLog = accesslog.NewLogger(s.mgmtClient, s.Logger, s.TrustedProxies)
s.startDebugEndpoint()
@@ -1298,7 +1274,6 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr
func (s *Server) proxyCapabilities() *proto.ProxyCapabilities {
supportsCrowdSec := s.crowdsecRegistry.Available()
supportsAppSec := s.appsecClient != nil
privateCapability := s.Private
// Always true: this build enforces ProxyMapping.private via the auth middleware.
supportsPrivateService := true
@@ -1306,7 +1281,6 @@ func (s *Server) proxyCapabilities() *proto.ProxyCapabilities {
SupportsCustomPorts: &s.SupportsCustomPorts,
RequireSubdomain: &s.RequireSubdomain,
SupportsCrowdsec: &supportsCrowdSec,
SupportsAppsec: &supportsAppSec,
Private: &privateCapability,
SupportsPrivateService: &supportsPrivateService,
}
@@ -1934,57 +1908,6 @@ func (s *Server) parseRestrictions(mapping *proto.ProxyMapping) *restrict.Filter
})
}
// initAppSec builds the shared AppSec client when an endpoint is configured.
// A configured-but-invalid endpoint is a startup error rather than a silent
// downgrade: services asking for enforce would otherwise fail closed on every
// request with no indication why.
//
// Runs before the management stream opens so the reported capability is stable;
// the auth middleware picks the client up separately once it exists.
func (s *Server) initAppSec() error {
if s.CrowdSecAppSecURL == "" {
return nil
}
if s.CrowdSecAPIKey == "" {
return errors.New("crowdsec appsec url is set but the crowdsec api key is empty")
}
// Share the middleware capture budget rather than opening a second pool:
// AppSec buffers before authentication, so its ceiling has to count against
// the same proxy-wide allowance the body tap draws from.
var budget appsec.Budget
if s.middlewareManager != nil {
budget = s.middlewareManager.Budget()
}
client, err := appsec.New(appsec.Config{
URL: s.CrowdSecAppSecURL,
APIKey: s.CrowdSecAPIKey,
Timeout: s.CrowdSecAppSecTimeout,
MaxBodyBytes: s.CrowdSecAppSecMaxBodyBytes,
Budget: budget,
Logger: log.NewEntry(s.Logger),
})
if err != nil {
return fmt.Errorf("init crowdsec appsec: %w", err)
}
s.appsecClient = client
s.Logger.Infof("CrowdSec AppSec inspection available at %s", s.CrowdSecAppSecURL)
return nil
}
// appSecMode resolves the per-service AppSec mode. A service asking for
// inspection on a proxy with no AppSec endpoint keeps its mode so the auth
// middleware fails closed for enforce, mirroring the CrowdSec behavior.
func (s *Server) appSecMode(mapping *proto.ProxyMapping) restrict.AppSecMode {
mode := restrict.ParseAppSecMode(mapping.GetAccessRestrictions().GetAppsecMode())
if mode.Enabled() && s.appsecClient == nil {
s.Logger.Warnf("service %s requests AppSec mode %q but proxy has no AppSec endpoint configured", mapping.GetId(), mode)
}
return mode
}
// releaseCrowdSec releases the CrowdSec bouncer reference for the given
// service if it had one.
func (s *Server) releaseCrowdSec(svcID types.ServiceID) {
@@ -2151,17 +2074,7 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions())
maxSessionAge := time.Duration(mapping.GetAuth().GetMaxSessionAgeSeconds()) * time.Second
settings := auth.DomainSettings{
Schemes: schemes,
SessionPublicKey: mapping.GetAuth().GetSessionKey(),
SessionExpiration: maxSessionAge,
AccountID: accountID,
ServiceID: svcID,
IPRestrictions: ipRestrictions,
Private: mapping.GetPrivate(),
AppSecMode: s.appSecMode(mapping),
}
if err := s.auth.AddDomain(mapping.GetDomain(), settings); err != nil {
if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions, mapping.GetPrivate()); err != nil {
return fmt.Errorf("auth setup for domain %s: %w", mapping.GetDomain(), err)
}
m := s.protoToMapping(ctx, mapping)

View File

@@ -3379,18 +3379,6 @@ components:
- "observe"
default: "off"
description: CrowdSec IP reputation mode. Only available when the proxy cluster supports CrowdSec.
appsec_mode:
type: string
enum:
- "off"
- "enforce"
- "observe"
default: "off"
description: >-
CrowdSec AppSec (WAF) request inspection mode. Only available when
the proxy cluster supports AppSec, and only applied to HTTP
services. "enforce" blocks requests the WAF flags; "observe" records
the verdict in the access log without blocking.
PasswordAuthConfig:
type: object
properties:
@@ -3527,10 +3515,6 @@ components:
type: boolean
description: Whether all active proxies in the cluster have CrowdSec configured
example: false
supports_appsec:
type: boolean
description: Whether all active proxies in the cluster have a CrowdSec AppSec (WAF) endpoint configured
example: false
private:
type: boolean
description: True when at least one connected proxy in this cluster is running embedded in a netbird client (`netbird proxy`) and serving over a WireGuard tunnel. Lets the dashboard distinguish per-peer / private clusters from centralised ones.
@@ -3590,10 +3574,6 @@ components:
type: boolean
description: Whether the proxy cluster has CrowdSec configured
example: false
supports_appsec:
type: boolean
description: Whether the proxy cluster has a CrowdSec AppSec (WAF) endpoint configured
example: false
supports_private:
type: boolean
description: Whether the proxy cluster supports private (NetBird-only) services. True when at least one connected proxy in the cluster runs embedded in a netbird client.

View File

@@ -17,27 +17,6 @@ const (
TokenAuthScopes tokenAuthContextKey = "TokenAuth.Scopes"
)
// Defines values for AccessRestrictionsAppsecMode.
const (
AccessRestrictionsAppsecModeEnforce AccessRestrictionsAppsecMode = "enforce"
AccessRestrictionsAppsecModeObserve AccessRestrictionsAppsecMode = "observe"
AccessRestrictionsAppsecModeOff AccessRestrictionsAppsecMode = "off"
)
// Valid indicates whether the value is a known member of the AccessRestrictionsAppsecMode enum.
func (e AccessRestrictionsAppsecMode) Valid() bool {
switch e {
case AccessRestrictionsAppsecModeEnforce:
return true
case AccessRestrictionsAppsecModeObserve:
return true
case AccessRestrictionsAppsecModeOff:
return true
default:
return false
}
}
// Defines values for AccessRestrictionsCrowdsecMode.
const (
AccessRestrictionsCrowdsecModeEnforce AccessRestrictionsCrowdsecMode = "enforce"
@@ -1561,9 +1540,6 @@ type AccessRestrictions struct {
// AllowedCountries ISO 3166-1 alpha-2 country codes to allow. If non-empty, only these countries are permitted.
AllowedCountries *[]string `json:"allowed_countries,omitempty"`
// AppsecMode CrowdSec AppSec (WAF) request inspection mode. Only available when the proxy cluster supports AppSec, and only applied to HTTP services. "enforce" blocks requests the WAF flags; "observe" records the verdict in the access log without blocking.
AppsecMode *AccessRestrictionsAppsecMode `json:"appsec_mode,omitempty"`
// BlockedCidrs CIDR blocklist. Connections from these CIDRs are rejected. Evaluated after allowed_cidrs.
BlockedCidrs *[]string `json:"blocked_cidrs,omitempty"`
@@ -1574,9 +1550,6 @@ type AccessRestrictions struct {
CrowdsecMode *AccessRestrictionsCrowdsecMode `json:"crowdsec_mode,omitempty"`
}
// AccessRestrictionsAppsecMode CrowdSec AppSec (WAF) request inspection mode. Only available when the proxy cluster supports AppSec, and only applied to HTTP services. "enforce" blocks requests the WAF flags; "observe" records the verdict in the access log without blocking.
type AccessRestrictionsAppsecMode string
// AccessRestrictionsCrowdsecMode CrowdSec IP reputation mode. Only available when the proxy cluster supports CrowdSec.
type AccessRestrictionsCrowdsecMode string
@@ -4755,9 +4728,6 @@ type ProxyCluster struct {
// RequireSubdomain Whether services on this cluster must include a subdomain label
RequireSubdomain *bool `json:"require_subdomain,omitempty"`
// SupportsAppsec Whether all active proxies in the cluster have a CrowdSec AppSec (WAF) endpoint configured
SupportsAppsec *bool `json:"supports_appsec,omitempty"`
// SupportsCrowdsec Whether all active proxies in the cluster have CrowdSec configured
SupportsCrowdsec *bool `json:"supports_crowdsec,omitempty"`
@@ -4826,9 +4796,6 @@ type ReverseProxyDomain struct {
// RequireSubdomain Whether a subdomain label is required in front of this domain. When true, the domain cannot be used bare.
RequireSubdomain *bool `json:"require_subdomain,omitempty"`
// SupportsAppsec Whether the proxy cluster has a CrowdSec AppSec (WAF) endpoint configured
SupportsAppsec *bool `json:"supports_appsec,omitempty"`
// SupportsCrowdsec Whether the proxy cluster has CrowdSec configured
SupportsCrowdsec *bool `json:"supports_crowdsec,omitempty"`

File diff suppressed because it is too large Load Diff

View File

@@ -73,10 +73,6 @@ message ProxyCapabilities {
optional bool private = 4;
// Whether the proxy enforces ProxyMapping.private (fails closed on ValidateTunnelPeer failure). Management MUST NOT stream private mappings to proxies that don't claim this.
optional bool supports_private_service = 5;
// Whether the proxy has a CrowdSec AppSec (WAF) endpoint configured and can
// inspect HTTP requests. Independent of supports_crowdsec: AppSec is a
// separate endpoint on the Security Engine and applies to HTTP services only.
optional bool supports_appsec = 6;
}
// GetMappingUpdateRequest is sent to initialise a mapping stream.
@@ -207,10 +203,6 @@ message AccessRestrictions {
repeated string blocked_countries = 4;
// CrowdSec IP reputation mode: "", "off", "enforce", or "observe".
string crowdsec_mode = 5;
// CrowdSec AppSec (WAF) request inspection mode: "", "off", "enforce", or
// "observe". HTTP services only: "enforce" and "observe" are rejected at
// validation for TCP/UDP/TLS services, which carry no requests to inspect.
string appsec_mode = 7;
}
message ProxyMapping {