Compare commits

...

3 Commits

Author SHA1 Message Date
a50f926463 feat: allow bypassing of basic auth from a list of authorized cidrs (#50)
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2025-08-05 16:24:41 +02:00
9d10a69b0d chore: update go and alpine docker image version
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2025-04-03 11:12:32 +02:00
8b6e75ae77 feat: use sentry tags instead of context for better observability
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2025-03-19 15:04:55 +01:00
9 changed files with 218 additions and 65 deletions

View File

@ -1,4 +1,4 @@
FROM reg.cadoles.com/proxy_cache/library/golang:1.23 AS BUILD
FROM reg.cadoles.com/proxy_cache/library/golang:1.24.2 AS build
RUN apt-get update \
&& apt-get install -y make
@ -33,7 +33,7 @@ RUN /src/dist/bouncer_linux_amd64_v1/bouncer -c '' config dump > /src/dist/bounc
&& yq -i '.bootstrap.lockTimeout = "30s"' /src/dist/bouncer_linux_amd64_v1/config.yml \
&& yq -i '.integrations.kubernetes.lockTimeout = "30s"' /src/dist/bouncer_linux_amd64_v1/config.yml
FROM reg.cadoles.com/proxy_cache/library/alpine:3.20 AS RUNTIME
FROM reg.cadoles.com/proxy_cache/library/alpine:3.21 AS runtime
RUN apk add --no-cache ca-certificates dumb-init
@ -41,10 +41,10 @@ ENTRYPOINT ["/usr/bin/dumb-init", "--"]
RUN mkdir -p /usr/local/bin /usr/share/bouncer/bin /etc/bouncer
COPY --from=BUILD /src/dist/bouncer_linux_amd64_v1/bouncer /usr/share/bouncer/bin/bouncer
COPY --from=BUILD /src/layers /usr/share/bouncer/layers
COPY --from=BUILD /src/templates /usr/share/bouncer/templates
COPY --from=BUILD /src/dist/bouncer_linux_amd64_v1/config.yml /etc/bouncer/config.yml
COPY --from=build /src/dist/bouncer_linux_amd64_v1/bouncer /usr/share/bouncer/bin/bouncer
COPY --from=build /src/layers /usr/share/bouncer/layers
COPY --from=build /src/templates /usr/share/bouncer/templates
COPY --from=build /src/dist/bouncer_linux_amd64_v1/config.yml /etc/bouncer/config.yml
RUN ln -s /usr/share/bouncer/bin/bouncer /usr/local/bin/bouncer

42
internal/cidr/match.go Normal file
View File

@ -0,0 +1,42 @@
package cidr
import (
"net"
"strings"
"github.com/pkg/errors"
)
func MatchAny(hostPort string, CIDRs ...string) (bool, error) {
var remoteHost string
if strings.Contains(hostPort, ":") {
var err error
remoteHost, _, err = net.SplitHostPort(hostPort)
if err != nil {
return false, errors.WithStack(err)
}
} else {
remoteHost = hostPort
}
remoteAddr := net.ParseIP(remoteHost)
if remoteAddr == nil {
return false, errors.Errorf("remote host '%s' is not a valid ip address", remoteHost)
}
for _, rawCIDR := range CIDRs {
_, net, err := net.ParseCIDR(rawCIDR)
if err != nil {
return false, errors.WithStack(err)
}
match := net.Contains(remoteAddr)
if !match {
continue
}
return true, nil
}
return false, nil
}

View File

@ -1,15 +1,13 @@
package network
package cidr
import (
"context"
"fmt"
"testing"
"github.com/pkg/errors"
)
func TestMatchAuthorizedCIDRs(t *testing.T) {
func TestMatchAny(t *testing.T) {
type testCase struct {
RemoteHostPort string
AuthorizedCIDRs []string
@ -56,14 +54,16 @@ func TestMatchAuthorizedCIDRs(t *testing.T) {
},
ExpectedResult: false,
},
{
RemoteHostPort: "[2001:0db8:0000:85a3:0000:0000:ac1f:8001]:8001",
AuthorizedCIDRs: []string{"2000::/3"},
ExpectedResult: true,
},
}
auth := Authenticator{}
ctx := context.Background()
for idx, tc := range testCases {
t.Run(fmt.Sprintf("Case #%d", idx), func(t *testing.T) {
result, err := auth.matchAnyAuthorizedCIDRs(ctx, tc.RemoteHostPort, tc.AuthorizedCIDRs)
result, err := MatchAny(tc.RemoteHostPort, tc.AuthorizedCIDRs...)
if g, e := result, tc.ExpectedResult; e != g {
t.Errorf("result: expected '%v', got '%v'", e, g)

View File

@ -80,9 +80,10 @@ func (d *Director) rewriteRequest(r *http.Request) (*http.Request, error) {
r = r.WithContext(proxyCtx)
if sentryScope, _ := SentryScope(ctx); sentryScope != nil {
sentryScope.SetContext("bouncer", sentry.Context{
"proxy_name": p.Name,
"proxy_target": r.URL.String(),
sentryScope.SetTags(map[string]string{
"bouncer.proxy.name": string(p.Name),
"bouncer.proxy.target.url": r.URL.String(),
"bouncer.proxy.target.host": r.URL.Host,
})
}

View File

@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"forge.cadoles.com/cadoles/bouncer/internal/cidr"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
@ -23,6 +24,16 @@ func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, lay
return nil, errors.WithStack(err)
}
matches, err := cidr.MatchAny(r.RemoteAddr, options.AuthorizedCIDRs...)
if err != nil {
return nil, errors.WithStack(err)
}
if matches {
user := authn.NewUser(r.RemoteAddr, map[string]any{})
return user, nil
}
username, password, ok := r.BasicAuth()
unauthorized := func() {

View File

@ -0,0 +1,130 @@
package basic
import (
"encoding/base64"
"net/http/httptest"
"testing"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
func TestAuthenticatorWithCredentials(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
r.Header.Set("Authorization", "Basic "+basicAuth("foo", "bar"))
authenticator := &Authenticator{}
layer := &store.Layer{
LayerHeader: store.LayerHeader{
Proxy: "test",
Name: "test",
Revision: 0,
Type: LayerType,
Enabled: true,
},
Options: store.LayerOptions{
"users": []map[string]any{
{
"username": "foo",
"passwordHash": "$2y$10$S3CfWRRMbOrOu3zUapZnfeU8xLtjH.MycWcvMRVHdc9RAty8lnn5q",
},
},
},
}
user, err := authenticator.Authenticate(w, r, layer)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if user == nil {
t.Fatalf("user should not be nil")
}
if e, g := "foo", user.Subject; e != g {
t.Fatalf("user.Subject: expected '%v', got '%v'", e, g)
}
r = httptest.NewRequest("GET", "/", nil)
w = httptest.NewRecorder()
r.Header.Set("Authorization", "Basic "+basicAuth("foo", "qsdq;sdqks"))
user, err = authenticator.Authenticate(w, r, layer)
if err == nil {
t.Errorf("err should not be nil")
}
if !errors.Is(err, authn.ErrSkipRequest) {
t.Errorf("err: expected %T, got %T", authn.ErrSkipRequest, err)
}
if user != nil {
t.Errorf("user should be nil")
}
}
func TestAuthenticatorWithAuthorizedRemoteAddr(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
// Authorized address
r.RemoteAddr = "192.168.30.21"
authenticator := &Authenticator{}
layer := &store.Layer{
LayerHeader: store.LayerHeader{
Proxy: "test",
Name: "test",
Revision: 0,
Type: LayerType,
Enabled: true,
},
Options: store.LayerOptions{
"users": []map[string]any{},
"authorizedCIDRs": []string{"192.168.30.1/24"},
},
}
user, err := authenticator.Authenticate(w, r, layer)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if user == nil {
t.Fatalf("user should not be nil")
}
if e, g := "192.168.30.21", user.Subject; e != g {
t.Fatalf("user.Subject: expected '%v', got '%v'", e, g)
}
r = httptest.NewRequest("GET", "/", nil)
w = httptest.NewRecorder()
// Unauthorized address
r.RemoteAddr = "192.168.40.36"
user, err = authenticator.Authenticate(w, r, layer)
if err == nil {
t.Errorf("err should not be nil")
}
if !errors.Is(err, authn.ErrSkipRequest) {
t.Errorf("err: expected %T, got %T", authn.ErrSkipRequest, err)
}
if user != nil {
t.Errorf("user should be nil")
}
}
func basicAuth(username, password string) string {
auth := username + ":" + password
return base64.StdEncoding.EncodeToString([]byte(auth))
}

View File

@ -34,6 +34,14 @@
],
"additionalProperties": false
}
},
"authorizedCIDRs": {
"title": "Liste des adresses réseau d'origine autorisées à contourner l'authentification (au format CIDR)",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false

View File

@ -9,8 +9,9 @@ import (
type LayerOptions struct {
authn.LayerOptions
Users []User `mapstructure:"users"`
Realm string `mapstructure:"realm"`
Users []User `mapstructure:"users"`
Realm string `mapstructure:"realm"`
AuthorizedCIDRs []string `mapstructure:"authorizedCIDRs"`
}
type User struct {
@ -21,9 +22,10 @@ type User struct {
func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) {
layerOptions := LayerOptions{
LayerOptions: authn.DefaultLayerOptions(),
Realm: "Restricted area",
Users: make([]User, 0),
LayerOptions: authn.DefaultLayerOptions(),
Realm: "Restricted area",
Users: make([]User, 0),
AuthorizedCIDRs: make([]string, 0),
}
config := mapstructure.DecoderConfig{

View File

@ -1,16 +1,13 @@
package network
import (
"context"
"net"
"net/http"
"strings"
"forge.cadoles.com/cadoles/bouncer/internal/cidr"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
"gitlab.com/wpetit/goweb/logger"
)
type Authenticator struct {
@ -18,14 +15,12 @@ type Authenticator struct {
// Authenticate implements authn.Authenticator.
func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, layer *store.Layer) (*authn.User, error) {
ctx := r.Context()
options, err := fromStoreOptions(layer.Options)
if err != nil {
return nil, errors.WithStack(err)
}
matches, err := a.matchAnyAuthorizedCIDRs(ctx, r.RemoteAddr, options.AuthorizedCIDRs)
matches, err := cidr.MatchAny(r.RemoteAddr, options.AuthorizedCIDRs...)
if err != nil {
return nil, errors.WithStack(err)
}
@ -49,42 +44,6 @@ func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, lay
return user, nil
}
func (a *Authenticator) matchAnyAuthorizedCIDRs(ctx context.Context, remoteHostPort string, CIDRs []string) (bool, error) {
var remoteHost string
if strings.Contains(remoteHostPort, ":") {
var err error
remoteHost, _, err = net.SplitHostPort(remoteHostPort)
if err != nil {
return false, errors.WithStack(err)
}
} else {
remoteHost = remoteHostPort
}
remoteAddr := net.ParseIP(remoteHost)
if remoteAddr == nil {
return false, errors.Errorf("remote host '%s' is not a valid ip address", remoteHost)
}
for _, rawCIDR := range CIDRs {
_, net, err := net.ParseCIDR(rawCIDR)
if err != nil {
return false, errors.WithStack(err)
}
match := net.Contains(remoteAddr)
if !match {
continue
}
return true, nil
}
logger.Debug(ctx, "comparing remote host with authorized cidrs", logger.F("remoteAddr", remoteAddr))
return false, nil
}
var (
_ authn.Authenticator = &Authenticator{}
)