fix(acls): auth bypass via forward auth - #1044
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughForward-auth now extracts the path component from ChangesForward-auth ACL validation
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant Traefik
participant getForwardAuthContext
participant AuthEnabledRule
Traefik->>getForwardAuthContext: Send x-forwarded-uri
getForwardAuthContext->>getForwardAuthContext: Parse URI and extract Path
getForwardAuthContext->>AuthEnabledRule: Evaluate path ACL
AuthEnabledRule-->>Traefik: Return allow or deny
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/controller/proxy_controller_test.go`:
- Around line 290-337: Update the ACL evaluator/matcher used for forwarded
requests to parse x-forwarded-uri and compare ACL path rules against the URL’s
exact path, excluding the query string and preventing substring matches.
Preserve allow matches only for the exact configured path and block only that
exact path so the four tests validate the intended behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 8967dd13-0c82-4c6e-ba02-bd63804bfd00
📒 Files selected for processing (2)
internal/controller/proxy_controller_test.gointernal/test/test.go
There was a problem hiding this comment.
Pull request overview
This PR strengthens the forward-auth (Traefik) ACL regression suite to prevent authentication bypasses where path-based ACLs could be satisfied via forwarded URI query strings or unintended substring matches.
Changes:
- Adds a new
app_path_blocktest app configuration to exercise block-list path ACL behavior. - Adds forward-auth tests ensuring allow/block path ACLs don’t match via query-string injection or path substrings.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| internal/test/test.go | Adds a new test app (app_path_block) used by proxy/controller ACL tests. |
| internal/controller/proxy_controller_test.go | Adds forward-auth (Traefik) regression tests for query-string and substring path matching. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
4d35c25 to
be1fb1c
Compare
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/service/access_controls_rules.go`:
- Around line 184-190: Update the configuredPath normalization in Evaluate to
preserve the root value "/" before trimming trailing slashes, ensuring a
path.block of "/" remains available to the root check and matches every request.
Keep empty non-root paths skipped while retaining the existing configuredPath
and path prefix matching behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 96c36242-4ebb-452f-8583-175eb519eb34
📒 Files selected for processing (5)
internal/controller/proxy_controller.gointernal/controller/proxy_controller_test.gointernal/service/access_controls_rules.gointernal/service/access_controls_rules_test.gointernal/test/test.go
💤 Files with no reviewable changes (1)
- internal/service/access_controls_rules_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
- internal/controller/proxy_controller_test.go
- internal/test/test.go
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
internal/service/access_controls_rules_test.go:533
TestAuthEnabledRuleno longer validates any of the path allow/block behaviors (match, non-match, allow override for blocked subpaths, etc.). Since the PR changes path matching semantics, this leaves the core ACL rule effectively untested at the service layer.
{
name: "denies when no path rules are configured",
ctx: &ACLContext{
ACLs: &model.App{},
Path: "/anything",
internal/controller/proxy_controller_test.go:340
- There’s no test asserting that a matching blocked path (e.g.
/blocked) is actually denied for the forward-auth flow; current additions only verify non-matches. A positive block assertion would catch regressions where block rules become no-ops.
},
{
description: "Ensure path allow ACL works on nginx auth request",
middlewares: []gin.HandlerFunc{},
be1fb1c to
3d5ab54
Compare
| if ctx.ACLs.Path.Block != "" { | ||
| regex, err := regexp.Compile(ctx.ACLs.Path.Block) | ||
|
|
||
| if err != nil { | ||
| rule.Log.App.Error().Err(err).Msg("Failed to compile block regex") | ||
| return EffectDeny | ||
| } | ||
| match := matchPathRule(ctx.ACLs.Path.Block, ctx.Path) | ||
|
|
||
| if !regex.MatchString(ctx.Path) { | ||
| if !match { | ||
| return EffectAllow |
| parsedURI, err := url.ParseRequestURI(uri) | ||
|
|
||
| if err != nil { | ||
| return ProxyContext{}, fmt.Errorf("invalid x-forwarded-uri: %w", err) | ||
| } | ||
|
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
internal/service/access_controls_rules_test.go:536
- This test currently asserts that allow path "/public" matches request path "/publicity", which is a substring match (not a subpath match). If allow rules are meant to match exact paths and prefix subpaths (e.g. "/public" and "/public/..."), update this test case to use a real subpath like "/public/index" so it doesn't codify the substring behavior.
ACLs: &model.App{
Path: model.AppPath{Allow: "/public"},
},
Path: "/publicity",
},
internal/model/config.go:336
- The updated field descriptions don’t explain the exact matching semantics (exact path vs subpath) or how to write a regex unambiguously. Given the implementation treats any value that starts and ends with "/" as a regex, it would help to document that prefix matches are segment-aware ("/public" matches "/public" and "/public/...", not "/publicity") and that regexes must be wrapped with leading/trailing slashes (e.g. "/^/public-[0-9]+$/").
type AppPath struct {
Allow string `description:"Comma-separated list of allowed path prefixes or slash-delimited regular expressions." yaml:"allow,omitempty"`
Block string `description:"Comma-separated list of blocked path prefixes or slash-delimited regular expressions." yaml:"block,omitempty"`
| if strings.HasPrefix(path, configuredPath) { | ||
| return true, nil | ||
| } |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/service/access_controls_rules.go`:
- Around line 196-203: Update the path-matching logic around the regex
compilation branch in the access-control rule evaluator so trailing-slash
entries such as "/admin/" remain literal prefix rules rather than being
interpreted as regexes. Introduce and use a non-overlapping regex syntax for
explicitly marked expressions, while preserving regex anchors and adding
allow/block regression coverage for ambiguous literal prefixes and anchored
patterns.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 53485d81-e9e2-4b03-a32f-e6f669f400e3
📒 Files selected for processing (4)
internal/controller/proxy_controller.gointernal/model/config.gointernal/service/access_controls_rules.gointernal/service/access_controls_rules_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
- internal/controller/proxy_controller.go
| // only apply regex if the path starts and ends with a slash, e.g. /regex/ | ||
| if strings.HasPrefix(configuredPath, "/") && strings.HasSuffix(configuredPath, "/") { | ||
| regex, err := regexp.Compile(configuredPath[1 : len(configuredPath)-1]) | ||
| if err != nil { | ||
| return false, fmt.Errorf("invalid path regex %q: %w", configuredPath, err) | ||
| } | ||
|
|
||
| if regex.MatchString(path) { |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
# Inventory potentially ambiguous slash-terminated path rules before changing syntax.
rg -n -i --glob '*.go' --glob '*.yml' --glob '*.yaml' --glob '*.md' \
'(allow|block)[[:space:]]*[:=][[:space:]]*["'\'']?/[[:alnum:]_.-]+/' .
# Inspect matcher and existing AuthEnabledRule coverage.
rg -n -C3 'regexp\.Compile|MatchString|HasPrefix\(configuredPath' \
internal/service/access_controls_rules.go
rg -n -C2 'TestAuthEnabledRule|Allow: "/admin/"|Block: "/admin/"' \
internal/service/access_controls_rules_test.goRepository: tinyauthapp/tinyauth
Length of output: 821
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
printf '--- access_controls_rules.go relevant section ---\n'
sed -n '150,225p' internal/service/access_controls_rules.go
printf '\n--- TestAuthEnabledRule tests around prefix /admin/public regex ---\n'
sed -n '500,625p' internal/service/access_controls_rules_test.go
printf '\n--- README/docs references for path syntax ---\n'
rg -n -C3 'allow|block|regex|/admin|Access|Auth' README.md docs internal -g '*.md' -g '*.go' 2>/dev/null || true
printf '\n--- Go regexp MatchString behavioral probe for slash delimiters ---\n'
cat >/tmp/rematch.go <<'EOPY'
package main
import (
"fmt"
"regexp"
"strings"
)
func configuredPathMatches(path, configuredPath string) bool {
if strings.HasPrefix(configuredPath, "/") && strings.HasSuffix(configuredPath, "/") {
regex, err := regexp.Compile(configuredPath[1 : len(configuredPath)-1])
if err != nil {
panic(err)
}
return regex.MatchString(path)
}
return strings.HasPrefix(path, configuredPath)
}
func main() {
tests := []struct {
path string
configuredPath string
wantAllowByRule bool
}{
{"/admin/public", "/admin/public", true},
{"/not-admin", "/admin/", true},
{"/superadmin", "/admin/", true},
{"/admin", "/admin/", true},
{"/admin/", "/admin/", true},
}
for _, tc := range tests {
fmt.Printf("%-15s matches config %-15s via %-30s = %v, wantByPrefix=%v\n",
tc.path, tc.configuredPath,
func() string {
p := tc.configuredPath
if strings.HasPrefix(p, "/") && strings.HasSuffix(p, "/") {
return fmt.Sprintf("unanchored regex: %q", p[1:len(p)-1])
}
return "prefix"
}(),
configuredPathMatches(tc.path, tc.configuredPath),
strings.HasPrefix(tc.path, tc.configuredPath),
)
}
}
EOPY
go run /tmp/rematch.goRepository: tinyauthapp/tinyauth
Length of output: 249
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- access_controls_rules.go relevant section ---'
sed -n '150,225p' internal/service/access_controls_rules.go
printf '%s\n' ''
printf '%s\n' '--- TestAuthEnabledRule tests around prefix /admin/public regex ---'
sed -n '500,625p' internal/service/access_controls_rules_test.go
printf '%s\n' ''
printf '%s\n' '--- README/docs references for path syntax ---'
rg -n -C3 'allow|block|regex|/admin|Access|Auth' README.md docs internal -g '*.md' -g '*.go' 2>/dev/null || true
printf '%s\n' ''
printf '%s\n' '--- Go regexp MatchString behavioral probe for slash delimiters ---'
cat >/tmp/rematch.go <<'EOPY'
package main
import (
"fmt"
"regexp"
"strings"
)
func configuredPathMatches(path, configuredPath string) bool {
if strings.HasPrefix(configuredPath, "/") && strings.HasSuffix(configuredPath, "/") {
regex, err := regexp.Compile(configuredPath[1 : len(configuredPath)-1])
if err != nil {
panic(err)
}
return regex.MatchString(path)
}
return strings.HasPrefix(path, configuredPath)
}
func main() {
tests := []struct {
path string
configuredPath string
wantAllowByRule bool
}{
{"/admin/public", "/admin/public", true},
{"/not-admin", "/admin/", true},
{"/superadmin", "/admin/", true},
{"/admin", "/admin/", true},
{"/admin/", "/admin/", true},
}
for _, tc := range tests {
fmt.Printf("%-15s matches config %-15s via %-30s = %v, wantByPrefix=%v\n",
tc.path, tc.configuredPath,
func() string {
p := tc.configuredPath
if strings.HasPrefix(p, "/") && strings.HasSuffix(p, "/") {
return fmt.Sprintf("unanchored regex: %q", p[1:len(p)-1])
}
prefix := tc.configuredPath
if strings.HasPrefix(prefix, "/") && strings.HasSuffix(prefix, "/") {
prefix = tc.configuredPath[1 : len(tc.configuredPath)-1]
}
return fmt.Sprintf("prefix: %q", prefix)
}(),
configuredPathMatches(tc.path, tc.configuredPath),
strings.HasPrefix(tc.path, tc.configuredPath),
)
}
}
EOPY
go run /tmp/rematch.goRepository: tinyauthapp/tinyauth
Length of output: 50377
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- access_controls_rules.go lines 140-220 ---'
sed -n '140,220p' internal/service/access_controls_rules.go
printf '%s\n' ''
printf '%s\n' '--- access_controls_rules_test.go lines 500-620 ---'
sed -n '500,620p' internal/service/access_controls_rules_test.go
printf '%s\n' ''
printf '%s\n' '--- docs/config references for path rules outside internal/service ---'
rg -n -C3 'allow|block|/admin|/.*public|regex|Regex|re:' README.md docs . --glob '*.md' --glob '*.yaml' --glob '*.yml' --glob '*.json' --glob '*.toml' 2>/dev/null | head -n 220 || true
printf '%s\n' ''
printf '%s\n' '--- Go regex behavior probe ---'
cat >/tmp/rematch.go <<'EOPY'
package main
import (
"fmt"
"regexp"
"strings"
)
type case struct {
path string
configuredPath string
}
func main() {
cases := []case{
{"/admin/public", "/admin/public"},
{"/not-admin", "/admin/"},
{"/superadmin", "/admin/"},
{"/admin", "/admin/"},
{"/admin/", "/admin/"},
{"/login/admin", "/admin/"},
{"/api/admin/v1", "/admin/"},
}
for _, tc := range cases {
pattern := tc.configuredPath
mode := "prefix"
if strings.HasPrefix(tc.configuredPath, "/") && strings.HasSuffix(tc.configuredPath, "/") {
pattern = tc.configuredPath[1 : len(tc.configuredPath)-1]
mode = "unanchored_regex"
}
got := false
if mode == "prefix" {
got = strings.HasPrefix(tc.path, tc.configuredPath)
} else {
got = regexp.MustCompile(pattern).MatchString(tc.path)
}
want = strings.HasPrefix(tc.path, tc.configuredPath)
fmt.Printf("%-15s config %-15s %s %q -> match=%v prefix=%v\n",
tc.path, tc.configuredPath, mode, pattern, got, want)
}
}
func main(){}
EOPY
go run /tmp/rematch.go 2>&1 || trueRepository: tinyauthapp/tinyauth
Length of output: 19245
Disambiguate regexes from trailing-slash prefixes.
/admin/ is a valid prefix rule but is compiled as unanchored regex admin; allow: /admin/ can therefore grant access to unrelated paths such as /not-admin, bypassing forward auth. Use a non-overlapping regex syntax, keep trailing-slash entries as literal prefixes, and add allow/block regressions for ambiguous literals and regex anchors.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/service/access_controls_rules.go` around lines 196 - 203, Update the
path-matching logic around the regex compilation branch in the access-control
rule evaluator so trailing-slash entries such as "/admin/" remain literal prefix
rules rather than being interpreted as regexes. Introduce and use a
non-overlapping regex syntax for explicitly marked expressions, while preserving
regex anchors and adding allow/block regression coverage for ambiguous literal
prefixes and anchored patterns.
3a377ad to
cce0eaa
Compare
| // only apply regex if the path starts and ends with a slash, e.g. /regex/ | ||
| if strings.HasPrefix(configuredPath, "/") && strings.HasSuffix(configuredPath, "/") { | ||
| regex, err := regexp.Compile(configuredPath[1 : len(configuredPath)-1]) | ||
| if err != nil { | ||
| return false, fmt.Errorf("invalid path regex %q: %w", configuredPath, err) | ||
| } | ||
|
|
||
| if regex.MatchString(path) { | ||
| return true, nil | ||
| } | ||
|
|
||
| continue | ||
| } |
| func matchPathRule(paths, path string) (bool, error) { | ||
| for _, configuredPath := range strings.Split(paths, ",") { | ||
| configuredPath = strings.TrimSpace(configuredPath) | ||
|
|
||
| if configuredPath == "/" { | ||
| return true, nil | ||
| } | ||
|
|
||
| if configuredPath == "" { | ||
| continue | ||
| } | ||
|
|
||
| // only apply regex if the path starts and ends with a slash, e.g. /regex/ | ||
| if strings.HasPrefix(configuredPath, "/") && strings.HasSuffix(configuredPath, "/") { | ||
| regex, err := regexp.Compile(configuredPath[1 : len(configuredPath)-1]) | ||
| if err != nil { | ||
| return false, fmt.Errorf("invalid path regex %q: %w", configuredPath, err) | ||
| } | ||
|
|
||
| if regex.MatchString(path) { | ||
| return true, nil | ||
| } | ||
|
|
||
| continue | ||
| } | ||
|
|
||
| if strings.HasPrefix(path, configuredPath) { | ||
| return true, nil | ||
| } | ||
| } | ||
|
|
||
| return false, nil | ||
| } |
There was a problem hiding this comment.
@scottmckendry we are changing behavior here and possibly breaking things. The way ACLs for paths should work is the following:
Normal whitelist
In case we have something like:
tinyauth.apps.foo.path.allow: /bar,/foo/bar,/hello
On every request we loop through the rules and check if the request URI has any prefix that matches. For example, /bar and all sub-paths such as /bar/foo/bar will be allowed. This is the current intended behavior.
Regex whitelist
For regex whitelist there is no looping, the check for regex happens before the comma split and we just check if the rule starts and ends with slashes (/). For example this example is allowed:
tinyauth.apps.foo.path.allow: /\/foo|bar/
This is not allowed:
tinyauth.apps.foo.path.allow /\/foo|bar/,/\/test/
There is no need to allow such thing since your comma-seperated whitelist can be recreated using regex and users using regex will know what they are doing. Now, as for how regex is treated, it's a simple match (not a prefix match). It's the user's responsibility to know how to configure regex matching correctly, not ours.
Hopefully this clears things out a bit on how the parsing of the whitelist should work.
| parsedURI, err := url.ParseRequestURI(uri) | ||
|
|
||
| if err != nil { | ||
| return ProxyContext{}, fmt.Errorf("invalid x-forwarded-uri: %w", err) | ||
| } | ||
|
|
||
| if parsedURI.Path == "" { | ||
| parsedURI.Path = "/" | ||
| } |
There was a problem hiding this comment.
We should do this for all auth module types and also strip out the query parameters immediately. Tinyauth won't need them in any part of the authentication chain anyway.
Summary by CodeRabbit