Compare commits

..

1 Commits

Author SHA1 Message Date
Xe Iaso 0491f1fac2 fix: patch GHSA-6wcg-mqvh-fcvg (#1616)
* fix: patch GHSA-6wcg-mqvh-fcvg

PR https://github.com/TecharoHQ/anubis/pull/1015 added the ability for
reverse proxies using Anubis in subrequest auth mode to look at the path
of a request as there are many rules in the wild that rely on checking
the path. This is how access to things like robots.txt or anything in the
.well-known directory is unaffected by Anubis.

However this logic was also enabled for non-subrequest deployments of Anubis,
meaning that a specially crafted request could include a /.well-known/
path in it and then get around Anubis with little effort.

This fix gates the logic behind a new plumbed variable named subrequestMode
that only fires when Anubis is running in subrequest auth mode. This
properly contains that workaround so that the logic does not fire in
most deployments.

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore: spelling

Signed-off-by: Xe Iaso <me@xeiaso.net>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
2026-05-08 19:17:25 -04:00
12 changed files with 140 additions and 48 deletions
+3
View File
@@ -120,6 +120,7 @@ fahedouch
fastcgi fastcgi
FCr FCr
fcrdns fcrdns
fcvg
fediverse fediverse
ffprobe ffprobe
fhdr fhdr
@@ -238,6 +239,7 @@ mnt
Mojeek Mojeek
mojeekbot mojeekbot
mozilla mozilla
mqvh
myclient myclient
mymaster mymaster
mypass mypass
@@ -387,6 +389,7 @@ vnd
VPS VPS
Vultr Vultr
WAIFU WAIFU
wcg
weblate weblate
webmaster webmaster
webpage webpage
+1 -1
View File
@@ -259,7 +259,7 @@ func main() {
} }
lg.Info("loading policy file", "fname", *policyFname) lg.Info("loading policy file", "fname", *policyFname)
policy, err := libanubis.LoadPoliciesOrDefault(ctx, *policyFname, *challengeDifficulty, *slogLevel) policy, err := libanubis.LoadPoliciesOrDefault(ctx, *policyFname, *challengeDifficulty, *slogLevel, strings.TrimSpace(*target) == "")
if err != nil { if err != nil {
log.Fatalf("can't parse policy file: %v", err) log.Fatalf("can't parse policy file: %v", err)
} }
+3 -3
View File
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- This changes the project to: --> <!-- This changes the project to: -->
- Patch [GHSA-6wcg-mqvh-fcvg](https://github.com/TecharoHQ/anubis/security/advisories/GHSA-6wcg-mqvh-fcvg) by containing subrequest logic to Anubis instances in subrequest mode.
- Move metrics server configuration to [the policy file](./admin/policies.mdx#metrics-server). - Move metrics server configuration to [the policy file](./admin/policies.mdx#metrics-server).
- Expose [pprof endpoints](https://pkg.go.dev/net/http/pprof) on the metrics listener to enable profiling Anubis in production. - Expose [pprof endpoints](https://pkg.go.dev/net/http/pprof) on the metrics listener to enable profiling Anubis in production.
- fix: prevent nil pointer panic in challenge validation when threshold rules match during PassChallenge (#1463) - fix: prevent nil pointer panic in challenge validation when threshold rules match during PassChallenge (#1463)
@@ -24,10 +25,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix CEL internal errors when iterating `headers`/`query` map wrappers by implementing map iterators for `HTTPHeaders` and `URLValues` ([#1465](https://github.com/TecharoHQ/anubis/pull/1465)). - Fix CEL internal errors when iterating `headers`/`query` map wrappers by implementing map iterators for `HTTPHeaders` and `URLValues` ([#1465](https://github.com/TecharoHQ/anubis/pull/1465)).
- Enable [metrics serving via TLS](./admin/policies.mdx#tls), including [mutual TLS (mTLS)](./admin/policies.mdx#mtls). - Enable [metrics serving via TLS](./admin/policies.mdx#tls), including [mutual TLS (mTLS)](./admin/policies.mdx#mtls).
- Enable [HTTP basic auth](./admin/policies.mdx#http-basic-authentication) for the metrics server. - Enable [HTTP basic auth](./admin/policies.mdx#http-basic-authentication) for the metrics server.
- Fix a bug in the dataset poisoning maze that could allow denial of service .[#1580](https://github.com/TecharoHQ/anubis/issues/1580). - Fix a bug in the dataset poisoning maze that could allow denial of service [#1580](https://github.com/TecharoHQ/anubis/issues/1580).
- Add config option to add ASN to logs/metrics. - Add config option to add ASN to logs/metrics.
- Log weight when issuing challenge. - Log weight when issuing challenge
- Waste bandwidth for headless chrome using the [Prompt API](https://developer.chrome.com/docs/ai/prompt-api).
## v1.25.0: Necron ## v1.25.0: Necron
+1 -1
View File
@@ -595,7 +595,7 @@ func spawnAnubisWithOptions(t *testing.T, basePrefix string) string {
fmt.Fprintf(w, "<html><body><span id=anubis-test>%d</span></body></html>", time.Now().Unix()) fmt.Fprintf(w, "<html><body><span id=anubis-test>%d</span></body></html>", time.Now().Unix())
}) })
policy, err := libanubis.LoadPoliciesOrDefault(t.Context(), "", anubis.DefaultDifficulty, "info") policy, err := libanubis.LoadPoliciesOrDefault(t.Context(), "", anubis.DefaultDifficulty, "info", false)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+2 -2
View File
@@ -58,7 +58,7 @@ func loadPolicies(t *testing.T, fname string, difficulty int) *policy.ParsedConf
t.Logf("loading policy file: %s", fname) t.Logf("loading policy file: %s", fname)
anubisPolicy, err := LoadPoliciesOrDefault(ctx, fname, difficulty, "info") anubisPolicy, err := LoadPoliciesOrDefault(ctx, fname, difficulty, "info", false)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -250,7 +250,7 @@ func TestLoadPolicies(t *testing.T) {
} }
defer fin.Close() defer fin.Close()
if _, err := policy.ParseConfig(t.Context(), fin, fname, 4, "info"); err != nil { if _, err := policy.ParseConfig(t.Context(), fin, fname, 4, "info", false); err != nil {
t.Fatal(err) t.Fatal(err)
} }
}) })
+2 -2
View File
@@ -55,7 +55,7 @@ type Options struct {
DifficultyInJWT bool DifficultyInJWT bool
} }
func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int, logLevel string) (*policy.ParsedConfig, error) { func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int, logLevel string, subrequestMode bool) (*policy.ParsedConfig, error) {
var fin io.ReadCloser var fin io.ReadCloser
var err error var err error
@@ -79,7 +79,7 @@ func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty
} }
}(fin) }(fin)
anubisPolicy, err := policy.ParseConfig(ctx, fin, fname, defaultDifficulty, logLevel) anubisPolicy, err := policy.ParseConfig(ctx, fin, fname, defaultDifficulty, logLevel, subrequestMode)
if err != nil { if err != nil {
return nil, fmt.Errorf("can't parse policy file %s: %w", fname, err) return nil, fmt.Errorf("can't parse policy file %s: %w", fname, err)
} }
+4 -4
View File
@@ -12,7 +12,7 @@ import (
) )
func TestInvalidChallengeMethod(t *testing.T) { func TestInvalidChallengeMethod(t *testing.T) {
if _, err := LoadPoliciesOrDefault(t.Context(), "testdata/invalid-challenge-method.yaml", 4, "info"); !errors.Is(err, policy.ErrChallengeRuleHasWrongAlgorithm) { if _, err := LoadPoliciesOrDefault(t.Context(), "testdata/invalid-challenge-method.yaml", 4, "info", false); !errors.Is(err, policy.ErrChallengeRuleHasWrongAlgorithm) {
t.Fatalf("wanted error %v but got %v", policy.ErrChallengeRuleHasWrongAlgorithm, err) t.Fatalf("wanted error %v but got %v", policy.ErrChallengeRuleHasWrongAlgorithm, err)
} }
} }
@@ -25,7 +25,7 @@ func TestBadConfigs(t *testing.T) {
for _, st := range finfos { for _, st := range finfos {
t.Run(st.Name(), func(t *testing.T) { t.Run(st.Name(), func(t *testing.T) {
if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("config", "testdata", "bad", st.Name()), anubis.DefaultDifficulty, "info"); err == nil { if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("config", "testdata", "bad", st.Name()), anubis.DefaultDifficulty, "info", false); err == nil {
t.Fatal(err) t.Fatal(err)
} else { } else {
t.Log(err) t.Log(err)
@@ -44,13 +44,13 @@ func TestGoodConfigs(t *testing.T) {
t.Run(st.Name(), func(t *testing.T) { t.Run(st.Name(), func(t *testing.T) {
t.Run("with-thoth", func(t *testing.T) { t.Run("with-thoth", func(t *testing.T) {
ctx := thothmock.WithMockThoth(t) ctx := thothmock.WithMockThoth(t)
if _, err := LoadPoliciesOrDefault(ctx, filepath.Join("config", "testdata", "good", st.Name()), anubis.DefaultDifficulty, "info"); err != nil { if _, err := LoadPoliciesOrDefault(ctx, filepath.Join("config", "testdata", "good", st.Name()), anubis.DefaultDifficulty, "info", false); err != nil {
t.Fatal(err) t.Fatal(err)
} }
}) })
t.Run("without-thoth", func(t *testing.T) { t.Run("without-thoth", func(t *testing.T) {
if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("config", "testdata", "good", st.Name()), anubis.DefaultDifficulty, "info"); err != nil { if _, err := LoadPoliciesOrDefault(t.Context(), filepath.Join("config", "testdata", "good", st.Name()), anubis.DefaultDifficulty, "info", false); err != nil {
t.Fatal(err) t.Fatal(err)
} }
}) })
+11 -8
View File
@@ -94,23 +94,26 @@ func (hmc *HeaderMatchesChecker) Hash() string {
} }
type PathChecker struct { type PathChecker struct {
regexp *regexp.Regexp regexp *regexp.Regexp
hash string hash string
subRequestMode bool
} }
func NewPathChecker(rexStr string) (checker.Impl, error) { func NewPathChecker(rexStr string, subrequestMode bool) (checker.Impl, error) {
rex, err := regexp.Compile(strings.TrimSpace(rexStr)) rex, err := regexp.Compile(strings.TrimSpace(rexStr))
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: regex %s failed parse: %w", ErrMisconfiguration, rexStr, err) return nil, fmt.Errorf("%w: regex %s failed parse: %w", ErrMisconfiguration, rexStr, err)
} }
return &PathChecker{rex, internal.FastHash(rexStr)}, nil return &PathChecker{rex, internal.FastHash(rexStr), subrequestMode}, nil
} }
func (pc *PathChecker) Check(r *http.Request) (bool, error) { func (pc *PathChecker) Check(r *http.Request) (bool, error) {
originalUrl := r.Header.Get("X-Original-URI") if pc.subRequestMode {
if originalUrl != "" { originalUrl := r.Header.Get("X-Original-URI")
if pc.regexp.MatchString(originalUrl) { if originalUrl != "" {
return true, nil if pc.regexp.MatchString(originalUrl) {
return true, nil
}
} }
} }
+107 -2
View File
@@ -272,8 +272,8 @@ func TestPathChecker_XOriginalURI(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Create the PathChecker // Create the PathChecker in subrequest mode so X-Original-URI is honored.
pc, err := NewPathChecker(tt.regex) pc, err := NewPathChecker(tt.regex, true)
if err != nil { if err != nil {
if !tt.expectError { if !tt.expectError {
t.Fatalf("NewPathChecker() unexpected error: %v", err) t.Fatalf("NewPathChecker() unexpected error: %v", err)
@@ -305,3 +305,108 @@ func TestPathChecker_XOriginalURI(t *testing.T) {
}) })
} }
} }
// TestPathChecker_GHSA_6wcg_mqvh_fcvg is a regression test for
// https://github.com/TecharoHQ/anubis/security/advisories/GHSA-6wcg-mqvh-fcvg.
//
// PR https://github.com/TecharoHQ/anubis/pull/1015 added the ability for
// reverse proxies using Anubis in subrequest auth mode to look at the path
// of a request as there are many rules in the wild that rely on checking
// the path. This is how access to things like robots.txt or anything in the
// .well-known directory is unaffected by Anubis.
//
// However this logic was also enabled for non-subrequest deployments of Anubis,
// meaning that a specially crafted request could include a /.well-known/
// path in it and then get around Anubis with little effort.
//
// This fix gates the logic behind a new plumbed variable named subrequestMode
// that only fires when Anubis is running in subrequest auth mode. This
// properly contains that workaround so that the logic does not fire in
// most deployments.
func TestPathChecker_GHSA_6wcg_mqvh_fcvg(t *testing.T) {
tests := []struct {
name string
regex string
urlPath string
xOriginalURI string
subRequestMode bool
want bool
}{
{
name: "default mode ignores spoofed X-Original-URI when real path matches",
regex: "^/admin/.*",
urlPath: "/admin/secret",
xOriginalURI: "/public/index",
subRequestMode: false,
want: true,
},
{
name: "default mode ignores spoofed X-Original-URI when real path does not match",
regex: "^/admin/.*",
urlPath: "/public/index",
xOriginalURI: "/admin/secret",
subRequestMode: false,
want: false,
},
{
name: "default mode without X-Original-URI matches real path",
regex: "^/admin/.*",
urlPath: "/admin/dashboard",
xOriginalURI: "",
subRequestMode: false,
want: true,
},
{
name: "subrequest mode honors X-Original-URI",
regex: "^/admin/.*",
urlPath: "/auth",
xOriginalURI: "/admin/secret",
subRequestMode: true,
want: true,
},
{
name: "subrequest mode falls back to URL.Path when X-Original-URI does not match",
regex: "^/admin/.*",
urlPath: "/admin/dashboard",
xOriginalURI: "/public/index",
subRequestMode: true,
want: true,
},
{
name: "subrequest mode with empty X-Original-URI uses URL.Path",
regex: "^/admin/.*",
urlPath: "/admin/dashboard",
xOriginalURI: "",
subRequestMode: true,
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pc, err := NewPathChecker(tt.regex, tt.subRequestMode)
if err != nil {
t.Fatalf("NewPathChecker(%q, %v) returned error: %v", tt.regex, tt.subRequestMode, err)
}
req, err := http.NewRequest(http.MethodGet, "http://example.com"+tt.urlPath, nil)
if err != nil {
t.Fatalf("http.NewRequest: %v", err)
}
if tt.xOriginalURI != "" {
req.Header.Set("X-Original-URI", tt.xOriginalURI)
}
got, err := pc.Check(req)
if err != nil {
t.Fatalf("Check() unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("Check() = %v, want %v (subRequestMode=%v, urlPath=%q, X-Original-URI=%q)",
got, tt.want, tt.subRequestMode, tt.urlPath, tt.xOriginalURI)
}
})
}
}
+2 -2
View File
@@ -60,7 +60,7 @@ func newParsedConfig(orig *config.Config) *ParsedConfig {
} }
} }
func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDifficulty int, logLevel string) (*ParsedConfig, error) { func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDifficulty int, logLevel string, subrequestMode bool) (*ParsedConfig, error) {
c, err := config.Load(fin, fname) c, err := config.Load(fin, fname)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -152,7 +152,7 @@ func ParseConfig(ctx context.Context, fin io.Reader, fname string, defaultDiffic
} }
if b.PathRegex != nil { if b.PathRegex != nil {
c, err := NewPathChecker(*b.PathRegex) c, err := NewPathChecker(*b.PathRegex, subrequestMode)
if err != nil { if err != nil {
validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s path regex: %w", b.Name, err)) validationErrs = append(validationErrs, fmt.Errorf("while processing rule %s path regex: %w", b.Name, err))
} else { } else {
+4 -4
View File
@@ -19,7 +19,7 @@ func TestDefaultPolicyMustParse(t *testing.T) {
} }
defer fin.Close() defer fin.Close()
if _, err := ParseConfig(ctx, fin, "botPolicies.yaml", anubis.DefaultDifficulty, "info"); err != nil { if _, err := ParseConfig(ctx, fin, "botPolicies.yaml", anubis.DefaultDifficulty, "info", false); err != nil {
t.Fatalf("can't parse config: %v", err) t.Fatalf("can't parse config: %v", err)
} }
} }
@@ -41,7 +41,7 @@ func TestGoodConfigs(t *testing.T) {
defer fin.Close() defer fin.Close()
ctx := thothmock.WithMockThoth(t) ctx := thothmock.WithMockThoth(t)
if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info"); err != nil { if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info", false); err != nil {
t.Fatal(err) t.Fatal(err)
} }
}) })
@@ -53,7 +53,7 @@ func TestGoodConfigs(t *testing.T) {
} }
defer fin.Close() defer fin.Close()
if _, err := ParseConfig(t.Context(), fin, fin.Name(), anubis.DefaultDifficulty, "info"); err != nil { if _, err := ParseConfig(t.Context(), fin, fin.Name(), anubis.DefaultDifficulty, "info", false); err != nil {
t.Fatal(err) t.Fatal(err)
} }
}) })
@@ -77,7 +77,7 @@ func TestBadConfigs(t *testing.T) {
} }
defer fin.Close() defer fin.Close()
if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info"); err == nil { if _, err := ParseConfig(ctx, fin, fin.Name(), anubis.DefaultDifficulty, "info", false); err == nil {
t.Fatal(err) t.Fatal(err)
} else { } else {
t.Log(err) t.Log(err)
-19
View File
@@ -93,31 +93,12 @@ const initTranslations = async () => {
translations = await loadTranslations(currentLang); translations = await loadTranslations(currentLang);
}; };
const wasteHeadlessChromeDisk = async () => {
if (window.LanguageModel !== undefined) {
const session = await window.LanguageModel.create({
initialPrompts: [
{
role: "system",
content: "You are a helpful assistant that responds in as many words as possible. Be verbose and answer questions fully with as much detail as possible."
},
{
role: "user",
content: "Why is the sky blue?",
},
],
})
}
};
const t = (key) => translations[`js_${key}`] || translations[key] || key; const t = (key) => translations[`js_${key}`] || translations[key] || key;
(async () => { (async () => {
// Initialize translations first // Initialize translations first
await initTranslations(); await initTranslations();
wasteHeadlessChromeDisk();
const dependencies = [ const dependencies = [
{ {
name: "Web Workers", name: "Web Workers",