Compare commits

...

4 Commits

Author SHA1 Message Date
Stavros 2737a25227 fix: don't point to nil local users in bootstrap app 2026-05-23 20:24:54 +03:00
Scott McKendry 7aa25210f5 feat(config): allow global bypass by ip (#889) 2026-05-23 19:58:48 +03:00
Stavros 55bef72639 fix: ensure domain defined in acls is included in host rules (#884) 2026-05-23 17:13:41 +03:00
Stavros ae17bd3b66 fix: do not log user context not found errors in proxy controller 2026-05-23 16:43:03 +03:00
7 changed files with 157 additions and 21 deletions
+6 -1
View File
@@ -97,7 +97,12 @@ func (app *BootstrapApp) Setup() error {
return fmt.Errorf("failed to load users: %w", err)
}
app.runtime.LocalUsers = *users
if users != nil {
app.runtime.LocalUsers = *users
} else {
log.App.Debug().Msg("No local users found, local authentication will not be available")
app.runtime.LocalUsers = []model.LocalUser{}
}
// load oauth whitelist
oauthWhitelist, err := utils.GetStringList(app.config.OAuth.Whitelist, app.config.OAuth.WhitelistFile)
+2 -2
View File
@@ -2,7 +2,6 @@ package bootstrap
import (
"fmt"
"os"
"github.com/tinyauthapp/tinyauth/internal/service"
@@ -126,7 +125,8 @@ func (app *BootstrapApp) setupPolicyEngine() error {
Config: app.config,
})
policyEngine.RegisterRule(service.RuleIPBypassed, &service.IPBypassedRule{
Log: app.log,
Log: app.log,
Config: app.config,
})
app.services.policyEngine = policyEngine
+4 -1
View File
@@ -160,7 +160,10 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
userContext, err := new(model.UserContext).NewFromGin(c)
if err != nil {
controller.log.App.Debug().Err(err).Msg("Failed to create user context from request, treating as unauthenticated")
// No user context found is not an issue
if !errors.Is(err, model.ErrUserContextNotFound) {
controller.log.App.Error().Err(err).Msg("Failed to create user context from request, treating as unauthenticated")
}
userContext = &model.UserContext{
Authenticated: false,
}
+3 -2
View File
@@ -154,8 +154,9 @@ type AddressClaim struct {
}
type IPConfig struct {
Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"`
Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"`
Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"`
Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"`
Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication entirely." yaml:"bypass"`
}
type OAuthConfig struct {
+14 -10
View File
@@ -182,13 +182,14 @@ type IPAllowedRule struct {
}
func (rule *IPAllowedRule) Evaluate(ctx *ACLContext) Effect {
if ctx.ACLs == nil {
return EffectAbstain
}
// merge global and per-app block/allow lists
blockedIps := append([]string{}, rule.Config.Auth.IP.Block...)
allowedIPs := append([]string{}, rule.Config.Auth.IP.Allow...)
// Merge the global and app IP filter
blockedIps := append(ctx.ACLs.IP.Block, rule.Config.Auth.IP.Block...)
allowedIPs := append(ctx.ACLs.IP.Allow, rule.Config.Auth.IP.Allow...)
if ctx.ACLs != nil {
blockedIps = append(blockedIps, ctx.ACLs.IP.Block...)
allowedIPs = append(allowedIPs, ctx.ACLs.IP.Allow...)
}
for _, blocked := range blockedIps {
match, err := utils.CheckIPFilter(blocked, ctx.IP.String())
@@ -224,15 +225,18 @@ func (rule *IPAllowedRule) Evaluate(ctx *ACLContext) Effect {
}
type IPBypassedRule struct {
Log *logger.Logger
Log *logger.Logger
Config model.Config
}
func (rule *IPBypassedRule) Evaluate(ctx *ACLContext) Effect {
if ctx.ACLs == nil {
return EffectDeny
// merge global and per-app bypass lists
bypassList := append([]string{}, rule.Config.Auth.IP.Bypass...)
if ctx.ACLs != nil {
bypassList = append(bypassList, ctx.ACLs.IP.Bypass...)
}
for _, bypassed := range ctx.ACLs.IP.Bypass {
for _, bypassed := range bypassList {
match, err := utils.CheckIPFilter(bypassed, ctx.IP.String())
if err != nil {
rule.Log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
+56 -5
View File
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)
@@ -558,12 +559,12 @@ func TestIPAllowedRule(t *testing.T) {
expected Effect
}{
{
name: "abstains when ACLs are nil",
name: "allows when ACLs are nil and no global lists configured",
ctx: &ACLContext{
ACLs: nil,
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectAbstain,
expected: EffectAllow,
},
{
name: "denies when IP matches app block list",
@@ -669,23 +670,70 @@ func TestIPBypassedRule(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
rule := &IPBypassedRule{Log: log}
defaultIPBR := &IPBypassedRule{Log: log}
globBypassIPBR := &IPBypassedRule{
Log: log,
Config: model.Config{Auth: model.AuthConfig{IP: model.IPConfig{Bypass: []string{"10.0.0.0/24"}}}},
}
tests := []struct {
name string
rule *IPBypassedRule
ctx *ACLContext
expected Effect
}{
{
name: "deny when ACLs are nil",
name: "deny when ACLs are nil and no global bypass",
rule: defaultIPBR,
ctx: &ACLContext{
ACLs: nil,
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectDeny,
},
{
name: "allows when ACLs are nil but IP matches global bypass",
rule: globBypassIPBR,
ctx: &ACLContext{
ACLs: nil,
IP: net.ParseIP("10.0.0.5"),
},
expected: EffectAllow,
},
{
name: "denies when ACLs are nil and IP does not match global bypass",
rule: globBypassIPBR,
ctx: &ACLContext{
ACLs: nil,
IP: net.ParseIP("192.168.1.1"),
},
expected: EffectDeny,
},
{
name: "allows when IP matches per-app bypass but not global bypass",
rule: defaultIPBR,
ctx: &ACLContext{
ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
},
IP: net.ParseIP("10.0.0.5"),
},
expected: EffectAllow,
},
{
name: "allows when IP matches global bypass but not per-app bypass",
rule: globBypassIPBR,
ctx: &ACLContext{
ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"172.16.0.0/24"}},
},
IP: net.ParseIP("10.0.0.5"),
},
expected: EffectAllow,
},
{
name: "allows when IP matches bypass list",
rule: defaultIPBR,
ctx: &ACLContext{
ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
@@ -696,6 +744,7 @@ func TestIPBypassedRule(t *testing.T) {
},
{
name: "denies when IP does not match bypass list",
rule: defaultIPBR,
ctx: &ACLContext{
ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
@@ -706,6 +755,7 @@ func TestIPBypassedRule(t *testing.T) {
},
{
name: "denies when bypass list is empty",
rule: defaultIPBR,
ctx: &ACLContext{
ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"),
@@ -714,6 +764,7 @@ func TestIPBypassedRule(t *testing.T) {
},
{
name: "skips invalid bypass entries and allows on later match",
rule: defaultIPBR,
ctx: &ACLContext{
ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"not-an-ip", "10.0.0.1"}},
@@ -726,7 +777,7 @@ func TestIPBypassedRule(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
assert.Equal(t, tt.expected, tt.rule.Evaluate(tt.ctx))
})
}
}
+72
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"time"
@@ -167,6 +168,68 @@ func (k *KubernetesService) getByAppName(appName string) *model.App {
return nil
}
func (k *KubernetesService) extractPaths(rule map[string]any) ([]string, error) {
http, found, err := unstructured.NestedMap(rule, "http")
if err != nil {
return nil, fmt.Errorf("reading http from rule: %w", err)
}
if !found {
return nil, nil
}
paths, found, err := unstructured.NestedSlice(http, "paths")
if err != nil {
return nil, fmt.Errorf("reading http.paths: %w", err)
}
if !found {
return nil, nil
}
var result []string
for _, p := range paths {
path, ok := p.(map[string]any)
if !ok {
continue
}
if p, ok := path["path"].(string); ok && p != "" {
result = append(result, p)
}
}
return result, nil
}
func (k *KubernetesService) extractHosts(item *unstructured.Unstructured) ([]string, error) {
rules, found, err := unstructured.NestedSlice(item.Object, "spec", "rules")
if err != nil {
return nil, fmt.Errorf("reading spec.rules: %w", err)
}
if !found {
return nil, nil
}
var hosts []string
for _, r := range rules {
rule, ok := r.(map[string]any)
if !ok {
continue
}
if host, ok := rule["host"].(string); ok && host != "" {
hosts = append(hosts, host)
}
paths, err := k.extractPaths(rule)
if err != nil {
// This is purely to warn users, it doesn't affect our ability to extract hosts so we won't fail the whole operation
k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from ingress rule")
continue
}
if len(paths) == 0 {
continue
}
if !slices.Contains(paths, "/") {
k.log.App.Warn().Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Strs("paths", paths).Msg("Ingress rule does not contain a catch-all path, another ingress may be able to bypass auth checks if it routes the same host with a different path. Consider adding a catch-all path to this rule to ensure auth checks are applied to all paths for this host.")
}
}
k.log.App.Trace().Strs("hosts", hosts).Msg("Extracted hosts from ingress rules")
return hosts, nil
}
func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
namespace := item.GetNamespace()
name := item.GetName()
@@ -175,6 +238,11 @@ func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
k.removeIngress(namespace, name)
return
}
hosts, err := k.extractHosts(item)
if err != nil {
k.removeIngress(namespace, name)
return
}
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
if err != nil {
k.log.App.Warn().Err(err).Str("namespace", namespace).Str("name", name).Msg("Failed to decode ingress labels, skipping")
@@ -186,6 +254,10 @@ func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
if appLabels.Config.Domain == "" {
continue
}
if len(hosts) > 0 && !slices.Contains(hosts, appLabels.Config.Domain) {
k.log.App.Warn().Str("namespace", namespace).Str("name", name).Str("appName", appName).Str("domain", appLabels.Config.Domain).Msg("App domain does not match any hosts defined in ingress rules, skipping")
continue
}
apps = append(apps, ingressApp{
domain: appLabels.Config.Domain,
appName: appName,