diff --git a/vulnerabilities/templates/advisory_detail.html b/vulnerabilities/templates/advisory_detail.html index fea5afde4..a8478645d 100644 --- a/vulnerabilities/templates/advisory_detail.html +++ b/vulnerabilities/templates/advisory_detail.html @@ -4,6 +4,7 @@ {% load static %} {% load show_cvss %} {% load url_filters %} +{% load diff_advisory_history %} {% block title %} VulnerableCode Advisory Details - {{ advisory.advisory_id }} @@ -23,6 +24,13 @@ + {% if is_snapshot %} +
+ Historical snapshot collected on {{ advisory.date_collected|date:"Y-m-d H:i" }} UTC. + View current version +
+ {% endif %} +
@@ -737,6 +870,36 @@ {% endblock %} \ No newline at end of file diff --git a/vulnerabilities/templates/advisory_package_details.html b/vulnerabilities/templates/advisory_package_details.html index 85c1dcaba..66853a77e 100644 --- a/vulnerabilities/templates/advisory_package_details.html +++ b/vulnerabilities/templates/advisory_package_details.html @@ -13,6 +13,13 @@ {% if advisoryv2 %}
+ {% if is_snapshot %} +
+ Historical snapshot collected on {{ advisoryv2.date_collected|date:"Y-m-d H:i" }} UTC. + View current version +
+ {% endif %} +
diff --git a/vulnerabilities/templatetags/diff_advisory_history.py b/vulnerabilities/templatetags/diff_advisory_history.py new file mode 100644 index 000000000..7d7933a05 --- /dev/null +++ b/vulnerabilities/templatetags/diff_advisory_history.py @@ -0,0 +1,118 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +from django import template +from packageurl import PackageURL + +register = template.Library() + + +@register.filter +def format_diff_for_ui(changes) -> dict: + """ + Example: + { + 'affected_packages': { + 'added': [{'package': {'type': 'pypi', 'name': 'requests', 'version': '2.25.0'}, 'affected_version_range': '==2.25.0', 'fixed_version_range': '==2.25.1'}], + 'removed': [{'package': {'type': 'pypi', 'name': 'requests', 'version': '2.24.0'}, 'affected_version_range': '==2.24.0', 'fixed_version_range': '==2.24.1'}] + } + } + should result in: + { + 'Affected Packages': { + 'added': [{'header': 'Affected package', 'attributes': [('PURL', 'pkg:pypi/requests@2.25.0'), ('Affected version', '==2.25.0'), ('Fixed Version', '==2.25.1')] }], + 'removed': [{'header': 'Affected package', 'attributes': [('PURL', 'pkg:pypi/requests@2.24.0'), ('Affected version', '==2.24.0'), ('Fixed Version', '==2.24.1')] }] + } + } + """ + formatted = {} + + for field, change in changes.items(): + label = field.replace("_", " ").title() + + if "old" in change or "new" in change: + formatted[label] = change + continue + + formatted[label] = {"added": [], "removed": []} + + for change_type in ["added", "removed"]: + for item in change.get(change_type, []): + if field == "affected_packages": + attributes = [] + + if package := item.get("package"): + package_string = ( + str(PackageURL(**package)) + if isinstance(package, dict) + else str(package) + ) + attributes.append(("PURL", package_string)) + + if affected_version_range := item.get("affected_version_range"): + attributes.append(("Affected version", affected_version_range)) + + if fixed_version_range := item.get("fixed_version_range"): + attributes.append(("Fixed Version", fixed_version_range)) + + formatted[label][change_type].append( + {"header": "Affected package", "attributes": attributes} + ) + + elif field == "references": + attributes = [] + + if reference_url := item.get("url"): + attributes.append(("URL", reference_url)) + + if reference_id := item.get("reference_id"): + attributes.append(("ID", reference_id)) + + formatted[label][change_type].append( + {"header": "Reference", "attributes": attributes} + ) + + elif field == "severities": + attributes = [] + + if scoring_system := item.get("system") or item.get("scoring_system"): + scoring_system_string = str(scoring_system) + if "cvss" in scoring_system_string.lower(): + scoring_system_string = scoring_system_string.upper() + else: + scoring_system_string = scoring_system_string.replace("_", " ").title() + attributes.append(("System", scoring_system_string)) + + if severity_value := item.get("value"): + attributes.append(("Value", severity_value)) + + if scoring_elements := item.get("scoring_elements"): + attributes.append(("Elements", scoring_elements)) + + formatted[label][change_type].append( + {"header": "Severity", "attributes": attributes} + ) + + elif field == "patches": + attributes = [] + + if patch_url := (item.get("url") or item.get("repository")): + attributes.append(("URL", patch_url)) + + if patch_commit := item.get("commit"): + attributes.append(("Commit", patch_commit)) + + formatted[label][change_type].append( + {"header": "Patch", "attributes": attributes} + ) + + else: + formatted[label][change_type].append(item) + + return formatted diff --git a/vulnerabilities/tests/test_advisory_history.py b/vulnerabilities/tests/test_advisory_history.py new file mode 100644 index 000000000..212db4b41 --- /dev/null +++ b/vulnerabilities/tests/test_advisory_history.py @@ -0,0 +1,59 @@ +import json +import os + +from vulnerabilities.templatetags.diff_advisory_history import format_diff_for_ui +from vulnerabilities.tests.util_tests import check_results_against_json + + +def test_advisory_history_diffing(): + """ + Test the diffing logic for historical advisory snapshots. + """ + from vulnerabilities.utils import diff_advisories_v2 + + base_dir = os.path.join(os.path.dirname(__file__), "test_data", "advisory_history") + + # Test GHSA-72hv-8253-57qq (Sample 1) + # See: https://github.com/github/advisory-database/commits/main/advisories/github-reviewed/2026/02/GHSA-72hv-8253-57qq/GHSA-72hv-8253-57qq.json + sample1_dir = os.path.join(base_dir, "GHSA-72hv-8253-57qq") + with open(os.path.join(sample1_dir, "normalised_history_GHSA-72hv-8253-57qq.json"), "r") as f: + sample1_data = json.load(f) + + sample1_diffs = [ + diff_advisories_v2(sample1_data[i], sample1_data[i + 1]) + for i in range(len(sample1_data) - 1) + ] + + sample1_expected_file = os.path.join(sample1_dir, "expected_diff_GHSA-72hv-8253-57qq.json") + check_results_against_json(sample1_diffs, sample1_expected_file) + + # Test GHSA-6rw7-vpxm-498p (Sample 2) + # See: https://github.com/github/advisory-database/commits/main/advisories/github-reviewed/2025/12/GHSA-6rw7-vpxm-498p/GHSA-6rw7-vpxm-498p.json + sample2_dir = os.path.join(base_dir, "GHSA-6rw7-vpxm-498p") + with open(os.path.join(sample2_dir, "normalised_history_GHSA-6rw7-vpxm-498p.json"), "r") as f: + sample2_data = json.load(f) + + sample2_diffs = [ + diff_advisories_v2(sample2_data[i], sample2_data[i + 1]) + for i in range(len(sample2_data) - 1) + ] + + sample2_expected_file = os.path.join(sample2_dir, "expected_diff_GHSA-6rw7-vpxm-498p.json") + check_results_against_json(sample2_diffs, sample2_expected_file) + + +def test_format_diff_for_ui(): + """ + Test the template tag logic that formats diffs for the UI. + """ + base_dir = os.path.join(os.path.dirname(__file__), "test_data", "advisory_history") + sample2_dir = os.path.join(base_dir, "GHSA-6rw7-vpxm-498p") + input_file = os.path.join(sample2_dir, "expected_diff_GHSA-6rw7-vpxm-498p.json") + + with open(input_file, "r") as f: + input_diffs = json.load(f) + + formatted_diffs = [format_diff_for_ui(diff) for diff in input_diffs] + + expected_file = os.path.join(sample2_dir, "expected_formatted_diff_GHSA-6rw7-vpxm-498p.json") + check_results_against_json(formatted_diffs, expected_file) diff --git a/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_diff_GHSA-6rw7-vpxm-498p.json b/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_diff_GHSA-6rw7-vpxm-498p.json new file mode 100644 index 000000000..a7265d0b5 --- /dev/null +++ b/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_diff_GHSA-6rw7-vpxm-498p.json @@ -0,0 +1,46 @@ +[ + { + "summary": { + "old": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs does not enforce limits for bracket notation (`a[]=1&a[]=2`), allowing attackers to cause denial-of-service via memory exhaustion. Applications using `arrayLimit` for DoS protection are vulnerable.\n\n### Details\n\nThe `arrayLimit` option only checks limits for indexed notation (`a[0]=1&a[1]=2`) but completely bypasses it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n**Test 1 - Basic bypass:**\n```bash\nnpm install qs\n```\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Test 2 - DoS demonstration:**\n```javascript\nconst qs = require('qs');\nconst attack = 'a[]=' + Array(10000).fill('x').join('&a[]=');\nconst result = qs.parse(attack, { arrayLimit: 100 });\nconsole.log(result.a.length); // Output: 10000 (should be max 100)\n```\n\n**Configuration:**\n- `arrayLimit: 5` (test 1) or `arrayLimit: 100` (test 2)\n- Use bracket notation: `a[]=value` (not indexed `a[0]=value`)\n\n### Impact\n\nDenial of Service via memory exhaustion. Affects applications using `qs.parse()` with user-controlled input and `arrayLimit` for protection.\n\n**Attack scenario:**\n1. Attacker sends HTTP request: `GET /api/search?filters[]=x&filters[]=x&...&filters[]=x` (100,000+ times)\n2. Application parses with `qs.parse(query, { arrayLimit: 100 })`\n3. qs ignores limit, parses all 100,000 elements into array\n4. Server memory exhausted \u2192 application crashes or becomes unresponsive\n5. Service unavailable for all users\n\n**Real-world impact:**\n- Single malicious request can crash server\n- No authentication required\n- Easy to automate and scale\n- Affects any endpoint parsing query strings with bracket notation\n\n### Suggested Fix\n\nAdd `arrayLimit` validation to the bracket notation handler. The code already calculates `currentArrayLength` at line 147-151, but it's not used in the bracket notation handler at line 159.\n\n**Current code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))\n ? []\n : utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Fixed code**:\n```javascript\nif (root === '[]' && options.parseArrays) {\n // Use currentArrayLength already calculated at line 147-151\n if (options.throwOnLimitExceeded && currentArrayLength >= options.arrayLimit) {\n throw new RangeError('Array limit exceeded. Only ' + options.arrayLimit + ' element' + (options.arrayLimit === 1 ? '' : 's') + ' allowed in an array.');\n }\n \n // If limit exceeded and not throwing, convert to object (consistent with indexed notation behavior)\n if (currentArrayLength >= options.arrayLimit) {\n obj = options.plainObjects ? { __proto__: null } : {};\n obj[currentArrayLength] = leaf;\n } else {\n obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))\n ? []\n : utils.combine([], leaf);\n }\n}\n```\n\nThis makes bracket notation behaviour consistent with indexed notation, enforcing `arrayLimit` and converting to object when limit is exceeded (per README documentation).", + "new": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs did not enforce limits for bracket notation (`a[]=1&a[]=2`), only for indexed notation (`a[0]=1`). This is a consistency bug; `arrayLimit` should apply uniformly across all array notations.\n\n**Note:** The default `parameterLimit` of 1000 effectively mitigates the DoS scenario originally described. With default options, bracket notation cannot produce arrays larger than `parameterLimit` regardless of `arrayLimit`, because each `a[]=value` consumes one parameter slot. The severity has been reduced accordingly.\n\n### Details\n\nThe `arrayLimit` option only checked limits for indexed notation (`a[0]=1&a[1]=2`) but did not enforce it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Note on parameterLimit interaction:** The original advisory's \"DoS demonstration\" claimed a length of 10,000, but `parameterLimit` (default: 1000) caps parsing to 1,000 parameters. With default options, the actual output is 1,000, not 10,000.\n\n### Impact\n\nConsistency bug in `arrayLimit` enforcement. With default `parameterLimit`, the practical DoS risk is negligible since `parameterLimit` already caps the total number of parsed parameters (and thus array elements from bracket notation). The risk increases only when `parameterLimit` is explicitly set to a very high value." + } + }, + { + "severities": { + "added": [ + { + "scoring_elements": "", + "system": "generic_textual", + "value": "MODERATE" + }, + { + "scoring_elements": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L", + "system": "cvssv3.1", + "value": "3.7" + }, + { + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:L", + "system": "cvssv4", + "value": "6.3" + } + ], + "removed": [ + { + "scoring_elements": "", + "system": "generic_textual", + "value": "HIGH" + }, + { + "scoring_elements": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + "system": "cvssv3.1", + "value": "7.5" + }, + { + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N", + "system": "cvssv4", + "value": "8.7" + } + ] + } + } +] \ No newline at end of file diff --git a/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_formatted_diff_GHSA-6rw7-vpxm-498p.json b/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_formatted_diff_GHSA-6rw7-vpxm-498p.json new file mode 100644 index 000000000..645b9d308 --- /dev/null +++ b/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_formatted_diff_GHSA-6rw7-vpxm-498p.json @@ -0,0 +1,110 @@ +[ + { + "Summary": { + "old": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs does not enforce limits for bracket notation (`a[]=1&a[]=2`), allowing attackers to cause denial-of-service via memory exhaustion. Applications using `arrayLimit` for DoS protection are vulnerable.\n\n### Details\n\nThe `arrayLimit` option only checks limits for indexed notation (`a[0]=1&a[1]=2`) but completely bypasses it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n**Test 1 - Basic bypass:**\n```bash\nnpm install qs\n```\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Test 2 - DoS demonstration:**\n```javascript\nconst qs = require('qs');\nconst attack = 'a[]=' + Array(10000).fill('x').join('&a[]=');\nconst result = qs.parse(attack, { arrayLimit: 100 });\nconsole.log(result.a.length); // Output: 10000 (should be max 100)\n```\n\n**Configuration:**\n- `arrayLimit: 5` (test 1) or `arrayLimit: 100` (test 2)\n- Use bracket notation: `a[]=value` (not indexed `a[0]=value`)\n\n### Impact\n\nDenial of Service via memory exhaustion. Affects applications using `qs.parse()` with user-controlled input and `arrayLimit` for protection.\n\n**Attack scenario:**\n1. Attacker sends HTTP request: `GET /api/search?filters[]=x&filters[]=x&...&filters[]=x` (100,000+ times)\n2. Application parses with `qs.parse(query, { arrayLimit: 100 })`\n3. qs ignores limit, parses all 100,000 elements into array\n4. Server memory exhausted \u2192 application crashes or becomes unresponsive\n5. Service unavailable for all users\n\n**Real-world impact:**\n- Single malicious request can crash server\n- No authentication required\n- Easy to automate and scale\n- Affects any endpoint parsing query strings with bracket notation\n\n### Suggested Fix\n\nAdd `arrayLimit` validation to the bracket notation handler. The code already calculates `currentArrayLength` at line 147-151, but it's not used in the bracket notation handler at line 159.\n\n**Current code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))\n ? []\n : utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Fixed code**:\n```javascript\nif (root === '[]' && options.parseArrays) {\n // Use currentArrayLength already calculated at line 147-151\n if (options.throwOnLimitExceeded && currentArrayLength >= options.arrayLimit) {\n throw new RangeError('Array limit exceeded. Only ' + options.arrayLimit + ' element' + (options.arrayLimit === 1 ? '' : 's') + ' allowed in an array.');\n }\n \n // If limit exceeded and not throwing, convert to object (consistent with indexed notation behavior)\n if (currentArrayLength >= options.arrayLimit) {\n obj = options.plainObjects ? { __proto__: null } : {};\n obj[currentArrayLength] = leaf;\n } else {\n obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))\n ? []\n : utils.combine([], leaf);\n }\n}\n```\n\nThis makes bracket notation behaviour consistent with indexed notation, enforcing `arrayLimit` and converting to object when limit is exceeded (per README documentation).", + "new": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs did not enforce limits for bracket notation (`a[]=1&a[]=2`), only for indexed notation (`a[0]=1`). This is a consistency bug; `arrayLimit` should apply uniformly across all array notations.\n\n**Note:** The default `parameterLimit` of 1000 effectively mitigates the DoS scenario originally described. With default options, bracket notation cannot produce arrays larger than `parameterLimit` regardless of `arrayLimit`, because each `a[]=value` consumes one parameter slot. The severity has been reduced accordingly.\n\n### Details\n\nThe `arrayLimit` option only checked limits for indexed notation (`a[0]=1&a[1]=2`) but did not enforce it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Note on parameterLimit interaction:** The original advisory's \"DoS demonstration\" claimed a length of 10,000, but `parameterLimit` (default: 1000) caps parsing to 1,000 parameters. With default options, the actual output is 1,000, not 10,000.\n\n### Impact\n\nConsistency bug in `arrayLimit` enforcement. With default `parameterLimit`, the practical DoS risk is negligible since `parameterLimit` already caps the total number of parsed parameters (and thus array elements from bracket notation). The risk increases only when `parameterLimit` is explicitly set to a very high value." + } + }, + { + "Severities": { + "added": [ + { + "header": "Severity", + "attributes": [ + [ + "System", + "Generic Textual" + ], + [ + "Value", + "MODERATE" + ] + ] + }, + { + "header": "Severity", + "attributes": [ + [ + "System", + "CVSSV3.1" + ], + [ + "Value", + "3.7" + ], + [ + "Elements", + "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L" + ] + ] + }, + { + "header": "Severity", + "attributes": [ + [ + "System", + "CVSSV4" + ], + [ + "Value", + "6.3" + ], + [ + "Elements", + "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:L" + ] + ] + } + ], + "removed": [ + { + "header": "Severity", + "attributes": [ + [ + "System", + "Generic Textual" + ], + [ + "Value", + "HIGH" + ] + ] + }, + { + "header": "Severity", + "attributes": [ + [ + "System", + "CVSSV3.1" + ], + [ + "Value", + "7.5" + ], + [ + "Elements", + "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + ] + ] + }, + { + "header": "Severity", + "attributes": [ + [ + "System", + "CVSSV4" + ], + [ + "Value", + "8.7" + ], + [ + "Elements", + "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + ] + ] + } + ] + } + } +] \ No newline at end of file diff --git a/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/normalised_history_GHSA-6rw7-vpxm-498p.json b/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/normalised_history_GHSA-6rw7-vpxm-498p.json new file mode 100644 index 000000000..71c0281a3 --- /dev/null +++ b/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/normalised_history_GHSA-6rw7-vpxm-498p.json @@ -0,0 +1,206 @@ +[ + { + "advisory_id": "GHSA-6rw7-vpxm-498p", + "aliases": [ + "CVE-2025-15284" + ], + "summary": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs does not enforce limits for bracket notation (`a[]=1&a[]=2`), allowing attackers to cause denial-of-service via memory exhaustion. Applications using `arrayLimit` for DoS protection are vulnerable.\n\n### Details\n\nThe `arrayLimit` option only checks limits for indexed notation (`a[0]=1&a[1]=2`) but completely bypasses it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n**Test 1 - Basic bypass:**\n```bash\nnpm install qs\n```\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Test 2 - DoS demonstration:**\n```javascript\nconst qs = require('qs');\nconst attack = 'a[]=' + Array(10000).fill('x').join('&a[]=');\nconst result = qs.parse(attack, { arrayLimit: 100 });\nconsole.log(result.a.length); // Output: 10000 (should be max 100)\n```\n\n**Configuration:**\n- `arrayLimit: 5` (test 1) or `arrayLimit: 100` (test 2)\n- Use bracket notation: `a[]=value` (not indexed `a[0]=value`)\n\n### Impact\n\nDenial of Service via memory exhaustion. Affects applications using `qs.parse()` with user-controlled input and `arrayLimit` for protection.\n\n**Attack scenario:**\n1. Attacker sends HTTP request: `GET /api/search?filters[]=x&filters[]=x&...&filters[]=x` (100,000+ times)\n2. Application parses with `qs.parse(query, { arrayLimit: 100 })`\n3. qs ignores limit, parses all 100,000 elements into array\n4. Server memory exhausted \u2192 application crashes or becomes unresponsive\n5. Service unavailable for all users\n\n**Real-world impact:**\n- Single malicious request can crash server\n- No authentication required\n- Easy to automate and scale\n- Affects any endpoint parsing query strings with bracket notation\n\n### Suggested Fix\n\nAdd `arrayLimit` validation to the bracket notation handler. The code already calculates `currentArrayLength` at line 147-151, but it's not used in the bracket notation handler at line 159.\n\n**Current code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))\n ? []\n : utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Fixed code**:\n```javascript\nif (root === '[]' && options.parseArrays) {\n // Use currentArrayLength already calculated at line 147-151\n if (options.throwOnLimitExceeded && currentArrayLength >= options.arrayLimit) {\n throw new RangeError('Array limit exceeded. Only ' + options.arrayLimit + ' element' + (options.arrayLimit === 1 ? '' : 's') + ' allowed in an array.');\n }\n \n // If limit exceeded and not throwing, convert to object (consistent with indexed notation behavior)\n if (currentArrayLength >= options.arrayLimit) {\n obj = options.plainObjects ? { __proto__: null } : {};\n obj[currentArrayLength] = leaf;\n } else {\n obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))\n ? []\n : utils.combine([], leaf);\n }\n}\n```\n\nThis makes bracket notation behaviour consistent with indexed notation, enforcing `arrayLimit` and converting to object when limit is exceeded (per README documentation).", + "affected_packages": [ + { + "package": { + "type": "npm", + "namespace": "", + "name": "qs", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:npm/<6.14.1", + "fixed_version_range": "vers:npm/6.14.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + } + ], + "references": [ + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/ljharb/qs" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/ljharb/qs/commit/3086902ecf7f088d0d1803887643ac6c03d415b9" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/ljharb/qs/security/advisories/GHSA-6rw7-vpxm-498p" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-15284" + } + ], + "severities": [ + { + "system": "cvssv3.1", + "value": "7.5", + "scoring_elements": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + }, + { + "system": "cvssv4", + "value": "8.7", + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + }, + { + "system": "generic_textual", + "value": "HIGH", + "scoring_elements": "" + } + ], + "weaknesses": [ + 20 + ], + "patches": [], + "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2025/12/GHSA-6rw7-vpxm-498p/GHSA-6rw7-vpxm-498p.json", + "date_published": "2025-12-30 21:02:54+00:00" + }, + { + "advisory_id": "GHSA-6rw7-vpxm-498p", + "aliases": [ + "CVE-2025-15284" + ], + "summary": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs did not enforce limits for bracket notation (`a[]=1&a[]=2`), only for indexed notation (`a[0]=1`). This is a consistency bug; `arrayLimit` should apply uniformly across all array notations.\n\n**Note:** The default `parameterLimit` of 1000 effectively mitigates the DoS scenario originally described. With default options, bracket notation cannot produce arrays larger than `parameterLimit` regardless of `arrayLimit`, because each `a[]=value` consumes one parameter slot. The severity has been reduced accordingly.\n\n### Details\n\nThe `arrayLimit` option only checked limits for indexed notation (`a[0]=1&a[1]=2`) but did not enforce it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Note on parameterLimit interaction:** The original advisory's \"DoS demonstration\" claimed a length of 10,000, but `parameterLimit` (default: 1000) caps parsing to 1,000 parameters. With default options, the actual output is 1,000, not 10,000.\n\n### Impact\n\nConsistency bug in `arrayLimit` enforcement. With default `parameterLimit`, the practical DoS risk is negligible since `parameterLimit` already caps the total number of parsed parameters (and thus array elements from bracket notation). The risk increases only when `parameterLimit` is explicitly set to a very high value.", + "affected_packages": [ + { + "package": { + "type": "npm", + "namespace": "", + "name": "qs", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:npm/<6.14.1", + "fixed_version_range": "vers:npm/6.14.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + } + ], + "references": [ + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/ljharb/qs" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/ljharb/qs/commit/3086902ecf7f088d0d1803887643ac6c03d415b9" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/ljharb/qs/security/advisories/GHSA-6rw7-vpxm-498p" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-15284" + } + ], + "severities": [ + { + "system": "cvssv3.1", + "value": "7.5", + "scoring_elements": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + }, + { + "system": "cvssv4", + "value": "8.7", + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + }, + { + "system": "generic_textual", + "value": "HIGH", + "scoring_elements": "" + } + ], + "weaknesses": [ + 20 + ], + "patches": [], + "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2025/12/GHSA-6rw7-vpxm-498p/GHSA-6rw7-vpxm-498p.json", + "date_published": "2025-12-30 21:02:54+00:00" + }, + { + "advisory_id": "GHSA-6rw7-vpxm-498p", + "aliases": [ + "CVE-2025-15284" + ], + "summary": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs did not enforce limits for bracket notation (`a[]=1&a[]=2`), only for indexed notation (`a[0]=1`). This is a consistency bug; `arrayLimit` should apply uniformly across all array notations.\n\n**Note:** The default `parameterLimit` of 1000 effectively mitigates the DoS scenario originally described. With default options, bracket notation cannot produce arrays larger than `parameterLimit` regardless of `arrayLimit`, because each `a[]=value` consumes one parameter slot. The severity has been reduced accordingly.\n\n### Details\n\nThe `arrayLimit` option only checked limits for indexed notation (`a[0]=1&a[1]=2`) but did not enforce it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Note on parameterLimit interaction:** The original advisory's \"DoS demonstration\" claimed a length of 10,000, but `parameterLimit` (default: 1000) caps parsing to 1,000 parameters. With default options, the actual output is 1,000, not 10,000.\n\n### Impact\n\nConsistency bug in `arrayLimit` enforcement. With default `parameterLimit`, the practical DoS risk is negligible since `parameterLimit` already caps the total number of parsed parameters (and thus array elements from bracket notation). The risk increases only when `parameterLimit` is explicitly set to a very high value.", + "affected_packages": [ + { + "package": { + "type": "npm", + "namespace": "", + "name": "qs", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:npm/<6.14.1", + "fixed_version_range": "vers:npm/6.14.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + } + ], + "references": [ + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/ljharb/qs" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/ljharb/qs/commit/3086902ecf7f088d0d1803887643ac6c03d415b9" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/ljharb/qs/security/advisories/GHSA-6rw7-vpxm-498p" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-15284" + } + ], + "severities": [ + { + "system": "cvssv3.1", + "value": "3.7", + "scoring_elements": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L" + }, + { + "system": "cvssv4", + "value": "6.3", + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:L" + }, + { + "system": "generic_textual", + "value": "MODERATE", + "scoring_elements": "" + } + ], + "weaknesses": [ + 20 + ], + "patches": [], + "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2025/12/GHSA-6rw7-vpxm-498p/GHSA-6rw7-vpxm-498p.json", + "date_published": "2025-12-30 21:02:54+00:00" + } +] \ No newline at end of file diff --git a/vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/expected_diff_GHSA-72hv-8253-57qq.json b/vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/expected_diff_GHSA-72hv-8253-57qq.json new file mode 100644 index 000000000..2e4141659 --- /dev/null +++ b/vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/expected_diff_GHSA-72hv-8253-57qq.json @@ -0,0 +1,154 @@ +[ + { + "references": { + "added": [ + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/commit/b0c428e6f993e1b5ece5c1c3cb2523e887cd52cf" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/pull/1555" + } + ], + "removed": [ + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/commit/a004e9789c2cc6b41b379d02d229d58474d9a738" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/issues/1538" + } + ] + } + }, + { + "affected_packages": { + "added": [], + "removed": [ + { + "affected_version_range": "vers:maven/<=2.18.5", + "fixed_by_commit_patches": [], + "fixed_version_range": "vers:maven/2.18.6", + "introduced_by_commit_patches": [], + "package": { + "name": "jackson-core", + "namespace": "tools.jackson.core", + "qualifiers": "", + "subpath": "", + "type": "maven", + "version": "" + } + }, + { + "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", + "fixed_by_commit_patches": [], + "fixed_version_range": "vers:maven/2.21.1", + "introduced_by_commit_patches": [], + "package": { + "name": "jackson-core", + "namespace": "tools.jackson.core", + "qualifiers": "", + "subpath": "", + "type": "maven", + "version": "" + } + }, + { + "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", + "fixed_by_commit_patches": [], + "fixed_version_range": "vers:maven/3.1.0", + "introduced_by_commit_patches": [], + "package": { + "name": "jackson-core", + "namespace": "com.fasterxml.jackson.core", + "qualifiers": "", + "subpath": "", + "type": "maven", + "version": "" + } + } + ] + }, + "severities": { + "added": [ + { + "scoring_elements": "", + "system": "generic_textual", + "value": "MODERATE" + }, + { + "scoring_elements": "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", + "system": "cvssv4", + "value": "6.9" + } + ], + "removed": [ + { + "scoring_elements": "", + "system": "generic_textual", + "value": "HIGH" + }, + { + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N", + "system": "cvssv4", + "value": "8.7" + } + ] + } + }, + { + "affected_packages": { + "added": [ + { + "affected_version_range": "vers:maven/<=2.18.5", + "fixed_by_commit_patches": [], + "fixed_version_range": "vers:maven/2.18.6", + "introduced_by_commit_patches": [], + "package": { + "name": "jackson-core", + "namespace": "tools.jackson.core", + "qualifiers": "", + "subpath": "", + "type": "maven", + "version": "" + } + }, + { + "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", + "fixed_by_commit_patches": [], + "fixed_version_range": "vers:maven/2.21.1", + "introduced_by_commit_patches": [], + "package": { + "name": "jackson-core", + "namespace": "tools.jackson.core", + "qualifiers": "", + "subpath": "", + "type": "maven", + "version": "" + } + }, + { + "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", + "fixed_by_commit_patches": [], + "fixed_version_range": "vers:maven/3.1.0", + "introduced_by_commit_patches": [], + "package": { + "name": "jackson-core", + "namespace": "com.fasterxml.jackson.core", + "qualifiers": "", + "subpath": "", + "type": "maven", + "version": "" + } + } + ], + "removed": [] + } + } +] \ No newline at end of file diff --git a/vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/normalised_history_GHSA-72hv-8253-57qq.json b/vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/normalised_history_GHSA-72hv-8253-57qq.json new file mode 100644 index 000000000..7cbd111e3 --- /dev/null +++ b/vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/normalised_history_GHSA-72hv-8253-57qq.json @@ -0,0 +1,484 @@ +[ + { + "advisory_id": "GHSA-72hv-8253-57qq", + "aliases": [], + "summary": "jackson-core: Number Length Constraint Bypass in Async Parser Leads to Potential DoS Condition\n### Summary\nThe non-blocking (async) JSON parser in `jackson-core` bypasses the `maxNumberLength` constraint (default: 1000 characters) defined in `StreamReadConstraints`. This allows an attacker to send JSON with arbitrarily long numbers through the async parser API, leading to excessive memory allocation and potential CPU exhaustion, resulting in a Denial of Service (DoS).\n\nThe standard synchronous parser correctly enforces this limit, but the async parser fails to do so, creating an inconsistent enforcement policy.\n\n### Details\nThe root cause is that the async parsing path in `NonBlockingUtf8JsonParserBase` (and related classes) does not call the methods responsible for number length validation.\n\n- The number parsing methods (e.g., `_finishNumberIntegralPart`) accumulate digits into the `TextBuffer` without any length checks.\n- After parsing, they call `_valueComplete()`, which finalizes the token but does **not** call `resetInt()` or `resetFloat()`.\n- The `resetInt()`/`resetFloat()` methods in `ParserBase` are where the `validateIntegerLength()` and `validateFPLength()` checks are performed.\n- Because this validation step is skipped, the `maxNumberLength` constraint is never enforced in the async code path.\n\n### PoC\nThe following JUnit 5 test demonstrates the vulnerability. It shows that the async parser accepts a 5,000-digit number, whereas the limit should be 1,000.\n\n```java\npackage tools.jackson.core.unittest.dos;\n\nimport java.nio.charset.StandardCharsets;\n\nimport org.junit.jupiter.api.Test;\n\nimport tools.jackson.core.*;\nimport tools.jackson.core.exc.StreamConstraintsException;\nimport tools.jackson.core.json.JsonFactory;\nimport tools.jackson.core.json.async.NonBlockingByteArrayJsonParser;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n/**\n * POC: Number Length Constraint Bypass in Non-Blocking (Async) JSON Parsers\n *\n * Authors: sprabhav7, rohan-repos\n * \n * maxNumberLength default = 1000 characters (digits).\n * A number with more than 1000 digits should be rejected by any parser.\n *\n * BUG: The async parser never calls resetInt()/resetFloat() which is where\n * validateIntegerLength()/validateFPLength() lives. Instead it calls\n * _valueComplete() which skips all number length validation.\n *\n * CWE-770: Allocation of Resources Without Limits or Throttling\n */\nclass AsyncParserNumberLengthBypassTest {\n\n private static final int MAX_NUMBER_LENGTH = 1000;\n private static final int TEST_NUMBER_LENGTH = 5000;\n\n private final JsonFactory factory = new JsonFactory();\n\n // CONTROL: Sync parser correctly rejects a number exceeding maxNumberLength\n @Test\n void syncParserRejectsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\t\t\n\t\t// Output to console\n System.out.println(\"[SYNC] Parsing \" + TEST_NUMBER_LENGTH + \"-digit number (limit: \" + MAX_NUMBER_LENGTH + \")\");\n try {\n try (JsonParser p = factory.createParser(ObjectReadContext.empty(), payload)) {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n System.out.println(\"[SYNC] Accepted number with \" + p.getText().length() + \" digits \u2014 UNEXPECTED\");\n }\n }\n }\n fail(\"Sync parser must reject a \" + TEST_NUMBER_LENGTH + \"-digit number\");\n } catch (StreamConstraintsException e) {\n System.out.println(\"[SYNC] Rejected with StreamConstraintsException: \" + e.getMessage());\n }\n }\n\n // VULNERABILITY: Async parser accepts the SAME number that sync rejects\n @Test\n void asyncParserAcceptsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\n NonBlockingByteArrayJsonParser p =\n (NonBlockingByteArrayJsonParser) factory.createNonBlockingByteArrayParser(ObjectReadContext.empty());\n p.feedInput(payload, 0, payload.length);\n p.endOfInput();\n\n boolean foundNumber = false;\n try {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n foundNumber = true;\n String numberText = p.getText();\n assertEquals(TEST_NUMBER_LENGTH, numberText.length(),\n \"Async parser silently accepted all \" + TEST_NUMBER_LENGTH + \" digits\");\n }\n }\n // Output to console\n System.out.println(\"[ASYNC INT] Accepted number with \" + TEST_NUMBER_LENGTH + \" digits \u2014 BUG CONFIRMED\");\n assertTrue(foundNumber, \"Parser should have produced a VALUE_NUMBER_INT token\");\n } catch (StreamConstraintsException e) {\n fail(\"Bug is fixed \u2014 async parser now correctly rejects long numbers: \" + e.getMessage());\n }\n p.close();\n }\n\n private byte[] buildPayloadWithLongInteger(int numDigits) {\n StringBuilder sb = new StringBuilder(numDigits + 10);\n sb.append(\"{\\\"v\\\":\");\n for (int i = 0; i < numDigits; i++) {\n sb.append((char) ('1' + (i % 9)));\n }\n sb.append('}');\n return sb.toString().getBytes(StandardCharsets.UTF_8);\n }\n}\n\n```\n\n\n### Impact\nA malicious actor can send a JSON document with an arbitrarily long number to an application using the async parser (e.g., in a Spring WebFlux or other reactive application). This can cause:\n1. **Memory Exhaustion:** Unbounded allocation of memory in the `TextBuffer` to store the number's digits, leading to an `OutOfMemoryError`.\n2. **CPU Exhaustion:** If the application subsequently calls `getBigIntegerValue()` or `getDecimalValue()`, the JVM can be tied up in O(n^2) `BigInteger` parsing operations, leading to a CPU-based DoS.\n\n### Suggested Remediation\n\nThe async parsing path should be updated to respect the `maxNumberLength` constraint. The simplest fix appears to ensure that `_valueComplete()` or a similar method in the async path calls the appropriate validation methods (`resetInt()` or `resetFloat()`) already present in `ParserBase`, mirroring the behavior of the synchronous parsers.\n\n**NOTE:** This research was performed in collaboration with [rohan-repos](https://github.com/rohan-repos)", + "affected_packages": [ + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/<=2.18.5", + "fixed_version_range": "vers:maven/2.18.6", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", + "fixed_version_range": "vers:maven/2.21.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", + "fixed_version_range": "vers:maven/3.1.0", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/<=2.18.5", + "fixed_version_range": "vers:maven/2.18.6", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", + "fixed_version_range": "vers:maven/2.21.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", + "fixed_version_range": "vers:maven/3.1.0", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + } + ], + "references": [ + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/commit/a004e9789c2cc6b41b379d02d229d58474d9a738" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/issues/1538" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/security/advisories/GHSA-72hv-8253-57qq" + } + ], + "severities": [ + { + "system": "cvssv4", + "value": "8.7", + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + }, + { + "system": "generic_textual", + "value": "HIGH", + "scoring_elements": "" + } + ], + "weaknesses": [ + 770 + ], + "patches": [], + "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/02/GHSA-72hv-8253-57qq/GHSA-72hv-8253-57qq.json", + "date_published": "2026-02-28 02:01:05+00:00" + }, + { + "advisory_id": "GHSA-72hv-8253-57qq", + "aliases": [], + "summary": "jackson-core: Number Length Constraint Bypass in Async Parser Leads to Potential DoS Condition\n### Summary\nThe non-blocking (async) JSON parser in `jackson-core` bypasses the `maxNumberLength` constraint (default: 1000 characters) defined in `StreamReadConstraints`. This allows an attacker to send JSON with arbitrarily long numbers through the async parser API, leading to excessive memory allocation and potential CPU exhaustion, resulting in a Denial of Service (DoS).\n\nThe standard synchronous parser correctly enforces this limit, but the async parser fails to do so, creating an inconsistent enforcement policy.\n\n### Details\nThe root cause is that the async parsing path in `NonBlockingUtf8JsonParserBase` (and related classes) does not call the methods responsible for number length validation.\n\n- The number parsing methods (e.g., `_finishNumberIntegralPart`) accumulate digits into the `TextBuffer` without any length checks.\n- After parsing, they call `_valueComplete()`, which finalizes the token but does **not** call `resetInt()` or `resetFloat()`.\n- The `resetInt()`/`resetFloat()` methods in `ParserBase` are where the `validateIntegerLength()` and `validateFPLength()` checks are performed.\n- Because this validation step is skipped, the `maxNumberLength` constraint is never enforced in the async code path.\n\n### PoC\nThe following JUnit 5 test demonstrates the vulnerability. It shows that the async parser accepts a 5,000-digit number, whereas the limit should be 1,000.\n\n```java\npackage tools.jackson.core.unittest.dos;\n\nimport java.nio.charset.StandardCharsets;\n\nimport org.junit.jupiter.api.Test;\n\nimport tools.jackson.core.*;\nimport tools.jackson.core.exc.StreamConstraintsException;\nimport tools.jackson.core.json.JsonFactory;\nimport tools.jackson.core.json.async.NonBlockingByteArrayJsonParser;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n/**\n * POC: Number Length Constraint Bypass in Non-Blocking (Async) JSON Parsers\n *\n * Authors: sprabhav7, rohan-repos\n * \n * maxNumberLength default = 1000 characters (digits).\n * A number with more than 1000 digits should be rejected by any parser.\n *\n * BUG: The async parser never calls resetInt()/resetFloat() which is where\n * validateIntegerLength()/validateFPLength() lives. Instead it calls\n * _valueComplete() which skips all number length validation.\n *\n * CWE-770: Allocation of Resources Without Limits or Throttling\n */\nclass AsyncParserNumberLengthBypassTest {\n\n private static final int MAX_NUMBER_LENGTH = 1000;\n private static final int TEST_NUMBER_LENGTH = 5000;\n\n private final JsonFactory factory = new JsonFactory();\n\n // CONTROL: Sync parser correctly rejects a number exceeding maxNumberLength\n @Test\n void syncParserRejectsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\t\t\n\t\t// Output to console\n System.out.println(\"[SYNC] Parsing \" + TEST_NUMBER_LENGTH + \"-digit number (limit: \" + MAX_NUMBER_LENGTH + \")\");\n try {\n try (JsonParser p = factory.createParser(ObjectReadContext.empty(), payload)) {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n System.out.println(\"[SYNC] Accepted number with \" + p.getText().length() + \" digits \u2014 UNEXPECTED\");\n }\n }\n }\n fail(\"Sync parser must reject a \" + TEST_NUMBER_LENGTH + \"-digit number\");\n } catch (StreamConstraintsException e) {\n System.out.println(\"[SYNC] Rejected with StreamConstraintsException: \" + e.getMessage());\n }\n }\n\n // VULNERABILITY: Async parser accepts the SAME number that sync rejects\n @Test\n void asyncParserAcceptsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\n NonBlockingByteArrayJsonParser p =\n (NonBlockingByteArrayJsonParser) factory.createNonBlockingByteArrayParser(ObjectReadContext.empty());\n p.feedInput(payload, 0, payload.length);\n p.endOfInput();\n\n boolean foundNumber = false;\n try {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n foundNumber = true;\n String numberText = p.getText();\n assertEquals(TEST_NUMBER_LENGTH, numberText.length(),\n \"Async parser silently accepted all \" + TEST_NUMBER_LENGTH + \" digits\");\n }\n }\n // Output to console\n System.out.println(\"[ASYNC INT] Accepted number with \" + TEST_NUMBER_LENGTH + \" digits \u2014 BUG CONFIRMED\");\n assertTrue(foundNumber, \"Parser should have produced a VALUE_NUMBER_INT token\");\n } catch (StreamConstraintsException e) {\n fail(\"Bug is fixed \u2014 async parser now correctly rejects long numbers: \" + e.getMessage());\n }\n p.close();\n }\n\n private byte[] buildPayloadWithLongInteger(int numDigits) {\n StringBuilder sb = new StringBuilder(numDigits + 10);\n sb.append(\"{\\\"v\\\":\");\n for (int i = 0; i < numDigits; i++) {\n sb.append((char) ('1' + (i % 9)));\n }\n sb.append('}');\n return sb.toString().getBytes(StandardCharsets.UTF_8);\n }\n}\n\n```\n\n\n### Impact\nA malicious actor can send a JSON document with an arbitrarily long number to an application using the async parser (e.g., in a Spring WebFlux or other reactive application). This can cause:\n1. **Memory Exhaustion:** Unbounded allocation of memory in the `TextBuffer` to store the number's digits, leading to an `OutOfMemoryError`.\n2. **CPU Exhaustion:** If the application subsequently calls `getBigIntegerValue()` or `getDecimalValue()`, the JVM can be tied up in O(n^2) `BigInteger` parsing operations, leading to a CPU-based DoS.\n\n### Suggested Remediation\n\nThe async parsing path should be updated to respect the `maxNumberLength` constraint. The simplest fix appears to ensure that `_valueComplete()` or a similar method in the async path calls the appropriate validation methods (`resetInt()` or `resetFloat()`) already present in `ParserBase`, mirroring the behavior of the synchronous parsers.\n\n**NOTE:** This research was performed in collaboration with [rohan-repos](https://github.com/rohan-repos)", + "affected_packages": [ + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/<=2.18.5", + "fixed_version_range": "vers:maven/2.18.6", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", + "fixed_version_range": "vers:maven/2.21.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", + "fixed_version_range": "vers:maven/3.1.0", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/<=2.18.5", + "fixed_version_range": "vers:maven/2.18.6", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", + "fixed_version_range": "vers:maven/2.21.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", + "fixed_version_range": "vers:maven/3.1.0", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + } + ], + "references": [ + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/commit/b0c428e6f993e1b5ece5c1c3cb2523e887cd52cf" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/pull/1555" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/security/advisories/GHSA-72hv-8253-57qq" + } + ], + "severities": [ + { + "system": "cvssv4", + "value": "8.7", + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + }, + { + "system": "generic_textual", + "value": "HIGH", + "scoring_elements": "" + } + ], + "weaknesses": [ + 770 + ], + "patches": [], + "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/02/GHSA-72hv-8253-57qq/GHSA-72hv-8253-57qq.json", + "date_published": "2026-02-28 02:01:05+00:00" + }, + { + "advisory_id": "GHSA-72hv-8253-57qq", + "aliases": [], + "summary": "jackson-core: Number Length Constraint Bypass in Async Parser Leads to Potential DoS Condition\n### Summary\nThe non-blocking (async) JSON parser in `jackson-core` bypasses the `maxNumberLength` constraint (default: 1000 characters) defined in `StreamReadConstraints`. This allows an attacker to send JSON with arbitrarily long numbers through the async parser API, leading to excessive memory allocation and potential CPU exhaustion, resulting in a Denial of Service (DoS).\n\nThe standard synchronous parser correctly enforces this limit, but the async parser fails to do so, creating an inconsistent enforcement policy.\n\n### Details\nThe root cause is that the async parsing path in `NonBlockingUtf8JsonParserBase` (and related classes) does not call the methods responsible for number length validation.\n\n- The number parsing methods (e.g., `_finishNumberIntegralPart`) accumulate digits into the `TextBuffer` without any length checks.\n- After parsing, they call `_valueComplete()`, which finalizes the token but does **not** call `resetInt()` or `resetFloat()`.\n- The `resetInt()`/`resetFloat()` methods in `ParserBase` are where the `validateIntegerLength()` and `validateFPLength()` checks are performed.\n- Because this validation step is skipped, the `maxNumberLength` constraint is never enforced in the async code path.\n\n### PoC\nThe following JUnit 5 test demonstrates the vulnerability. It shows that the async parser accepts a 5,000-digit number, whereas the limit should be 1,000.\n\n```java\npackage tools.jackson.core.unittest.dos;\n\nimport java.nio.charset.StandardCharsets;\n\nimport org.junit.jupiter.api.Test;\n\nimport tools.jackson.core.*;\nimport tools.jackson.core.exc.StreamConstraintsException;\nimport tools.jackson.core.json.JsonFactory;\nimport tools.jackson.core.json.async.NonBlockingByteArrayJsonParser;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n/**\n * POC: Number Length Constraint Bypass in Non-Blocking (Async) JSON Parsers\n *\n * Authors: sprabhav7, rohan-repos\n * \n * maxNumberLength default = 1000 characters (digits).\n * A number with more than 1000 digits should be rejected by any parser.\n *\n * BUG: The async parser never calls resetInt()/resetFloat() which is where\n * validateIntegerLength()/validateFPLength() lives. Instead it calls\n * _valueComplete() which skips all number length validation.\n *\n * CWE-770: Allocation of Resources Without Limits or Throttling\n */\nclass AsyncParserNumberLengthBypassTest {\n\n private static final int MAX_NUMBER_LENGTH = 1000;\n private static final int TEST_NUMBER_LENGTH = 5000;\n\n private final JsonFactory factory = new JsonFactory();\n\n // CONTROL: Sync parser correctly rejects a number exceeding maxNumberLength\n @Test\n void syncParserRejectsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\t\t\n\t\t// Output to console\n System.out.println(\"[SYNC] Parsing \" + TEST_NUMBER_LENGTH + \"-digit number (limit: \" + MAX_NUMBER_LENGTH + \")\");\n try {\n try (JsonParser p = factory.createParser(ObjectReadContext.empty(), payload)) {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n System.out.println(\"[SYNC] Accepted number with \" + p.getText().length() + \" digits \u2014 UNEXPECTED\");\n }\n }\n }\n fail(\"Sync parser must reject a \" + TEST_NUMBER_LENGTH + \"-digit number\");\n } catch (StreamConstraintsException e) {\n System.out.println(\"[SYNC] Rejected with StreamConstraintsException: \" + e.getMessage());\n }\n }\n\n // VULNERABILITY: Async parser accepts the SAME number that sync rejects\n @Test\n void asyncParserAcceptsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\n NonBlockingByteArrayJsonParser p =\n (NonBlockingByteArrayJsonParser) factory.createNonBlockingByteArrayParser(ObjectReadContext.empty());\n p.feedInput(payload, 0, payload.length);\n p.endOfInput();\n\n boolean foundNumber = false;\n try {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n foundNumber = true;\n String numberText = p.getText();\n assertEquals(TEST_NUMBER_LENGTH, numberText.length(),\n \"Async parser silently accepted all \" + TEST_NUMBER_LENGTH + \" digits\");\n }\n }\n // Output to console\n System.out.println(\"[ASYNC INT] Accepted number with \" + TEST_NUMBER_LENGTH + \" digits \u2014 BUG CONFIRMED\");\n assertTrue(foundNumber, \"Parser should have produced a VALUE_NUMBER_INT token\");\n } catch (StreamConstraintsException e) {\n fail(\"Bug is fixed \u2014 async parser now correctly rejects long numbers: \" + e.getMessage());\n }\n p.close();\n }\n\n private byte[] buildPayloadWithLongInteger(int numDigits) {\n StringBuilder sb = new StringBuilder(numDigits + 10);\n sb.append(\"{\\\"v\\\":\");\n for (int i = 0; i < numDigits; i++) {\n sb.append((char) ('1' + (i % 9)));\n }\n sb.append('}');\n return sb.toString().getBytes(StandardCharsets.UTF_8);\n }\n}\n\n```\n\n\n### Impact\nA malicious actor can send a JSON document with an arbitrarily long number to an application using the async parser (e.g., in a Spring WebFlux or other reactive application). This can cause:\n1. **Memory Exhaustion:** Unbounded allocation of memory in the `TextBuffer` to store the number's digits, leading to an `OutOfMemoryError`.\n2. **CPU Exhaustion:** If the application subsequently calls `getBigIntegerValue()` or `getDecimalValue()`, the JVM can be tied up in O(n^2) `BigInteger` parsing operations, leading to a CPU-based DoS.\n\n### Suggested Remediation\n\nThe async parsing path should be updated to respect the `maxNumberLength` constraint. The simplest fix appears to ensure that `_valueComplete()` or a similar method in the async path calls the appropriate validation methods (`resetInt()` or `resetFloat()`) already present in `ParserBase`, mirroring the behavior of the synchronous parsers.\n\n**NOTE:** This research was performed in collaboration with [rohan-repos](https://github.com/rohan-repos)", + "affected_packages": [ + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/<=2.18.5", + "fixed_version_range": "vers:maven/2.18.6", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", + "fixed_version_range": "vers:maven/2.21.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", + "fixed_version_range": "vers:maven/3.1.0", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + } + ], + "references": [ + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/commit/b0c428e6f993e1b5ece5c1c3cb2523e887cd52cf" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/pull/1555" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/security/advisories/GHSA-72hv-8253-57qq" + } + ], + "severities": [ + { + "system": "cvssv4", + "value": "6.9", + "scoring_elements": "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" + }, + { + "system": "generic_textual", + "value": "MODERATE", + "scoring_elements": "" + } + ], + "weaknesses": [ + 770 + ], + "patches": [], + "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/02/GHSA-72hv-8253-57qq/GHSA-72hv-8253-57qq.json", + "date_published": "2026-02-28 02:01:05+00:00" + }, + { + "advisory_id": "GHSA-72hv-8253-57qq", + "aliases": [], + "summary": "jackson-core: Number Length Constraint Bypass in Async Parser Leads to Potential DoS Condition\n### Summary\nThe non-blocking (async) JSON parser in `jackson-core` bypasses the `maxNumberLength` constraint (default: 1000 characters) defined in `StreamReadConstraints`. This allows an attacker to send JSON with arbitrarily long numbers through the async parser API, leading to excessive memory allocation and potential CPU exhaustion, resulting in a Denial of Service (DoS).\n\nThe standard synchronous parser correctly enforces this limit, but the async parser fails to do so, creating an inconsistent enforcement policy.\n\n### Details\nThe root cause is that the async parsing path in `NonBlockingUtf8JsonParserBase` (and related classes) does not call the methods responsible for number length validation.\n\n- The number parsing methods (e.g., `_finishNumberIntegralPart`) accumulate digits into the `TextBuffer` without any length checks.\n- After parsing, they call `_valueComplete()`, which finalizes the token but does **not** call `resetInt()` or `resetFloat()`.\n- The `resetInt()`/`resetFloat()` methods in `ParserBase` are where the `validateIntegerLength()` and `validateFPLength()` checks are performed.\n- Because this validation step is skipped, the `maxNumberLength` constraint is never enforced in the async code path.\n\n### PoC\nThe following JUnit 5 test demonstrates the vulnerability. It shows that the async parser accepts a 5,000-digit number, whereas the limit should be 1,000.\n\n```java\npackage tools.jackson.core.unittest.dos;\n\nimport java.nio.charset.StandardCharsets;\n\nimport org.junit.jupiter.api.Test;\n\nimport tools.jackson.core.*;\nimport tools.jackson.core.exc.StreamConstraintsException;\nimport tools.jackson.core.json.JsonFactory;\nimport tools.jackson.core.json.async.NonBlockingByteArrayJsonParser;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n/**\n * POC: Number Length Constraint Bypass in Non-Blocking (Async) JSON Parsers\n *\n * Authors: sprabhav7, rohan-repos\n * \n * maxNumberLength default = 1000 characters (digits).\n * A number with more than 1000 digits should be rejected by any parser.\n *\n * BUG: The async parser never calls resetInt()/resetFloat() which is where\n * validateIntegerLength()/validateFPLength() lives. Instead it calls\n * _valueComplete() which skips all number length validation.\n *\n * CWE-770: Allocation of Resources Without Limits or Throttling\n */\nclass AsyncParserNumberLengthBypassTest {\n\n private static final int MAX_NUMBER_LENGTH = 1000;\n private static final int TEST_NUMBER_LENGTH = 5000;\n\n private final JsonFactory factory = new JsonFactory();\n\n // CONTROL: Sync parser correctly rejects a number exceeding maxNumberLength\n @Test\n void syncParserRejectsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\t\t\n\t\t// Output to console\n System.out.println(\"[SYNC] Parsing \" + TEST_NUMBER_LENGTH + \"-digit number (limit: \" + MAX_NUMBER_LENGTH + \")\");\n try {\n try (JsonParser p = factory.createParser(ObjectReadContext.empty(), payload)) {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n System.out.println(\"[SYNC] Accepted number with \" + p.getText().length() + \" digits \u2014 UNEXPECTED\");\n }\n }\n }\n fail(\"Sync parser must reject a \" + TEST_NUMBER_LENGTH + \"-digit number\");\n } catch (StreamConstraintsException e) {\n System.out.println(\"[SYNC] Rejected with StreamConstraintsException: \" + e.getMessage());\n }\n }\n\n // VULNERABILITY: Async parser accepts the SAME number that sync rejects\n @Test\n void asyncParserAcceptsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\n NonBlockingByteArrayJsonParser p =\n (NonBlockingByteArrayJsonParser) factory.createNonBlockingByteArrayParser(ObjectReadContext.empty());\n p.feedInput(payload, 0, payload.length);\n p.endOfInput();\n\n boolean foundNumber = false;\n try {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n foundNumber = true;\n String numberText = p.getText();\n assertEquals(TEST_NUMBER_LENGTH, numberText.length(),\n \"Async parser silently accepted all \" + TEST_NUMBER_LENGTH + \" digits\");\n }\n }\n // Output to console\n System.out.println(\"[ASYNC INT] Accepted number with \" + TEST_NUMBER_LENGTH + \" digits \u2014 BUG CONFIRMED\");\n assertTrue(foundNumber, \"Parser should have produced a VALUE_NUMBER_INT token\");\n } catch (StreamConstraintsException e) {\n fail(\"Bug is fixed \u2014 async parser now correctly rejects long numbers: \" + e.getMessage());\n }\n p.close();\n }\n\n private byte[] buildPayloadWithLongInteger(int numDigits) {\n StringBuilder sb = new StringBuilder(numDigits + 10);\n sb.append(\"{\\\"v\\\":\");\n for (int i = 0; i < numDigits; i++) {\n sb.append((char) ('1' + (i % 9)));\n }\n sb.append('}');\n return sb.toString().getBytes(StandardCharsets.UTF_8);\n }\n}\n\n```\n\n\n### Impact\nA malicious actor can send a JSON document with an arbitrarily long number to an application using the async parser (e.g., in a Spring WebFlux or other reactive application). This can cause:\n1. **Memory Exhaustion:** Unbounded allocation of memory in the `TextBuffer` to store the number's digits, leading to an `OutOfMemoryError`.\n2. **CPU Exhaustion:** If the application subsequently calls `getBigIntegerValue()` or `getDecimalValue()`, the JVM can be tied up in O(n^2) `BigInteger` parsing operations, leading to a CPU-based DoS.\n\n### Suggested Remediation\n\nThe async parsing path should be updated to respect the `maxNumberLength` constraint. The simplest fix appears to ensure that `_valueComplete()` or a similar method in the async path calls the appropriate validation methods (`resetInt()` or `resetFloat()`) already present in `ParserBase`, mirroring the behavior of the synchronous parsers.\n\n**NOTE:** This research was performed in collaboration with [rohan-repos](https://github.com/rohan-repos)", + "affected_packages": [ + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/<=2.18.5", + "fixed_version_range": "vers:maven/2.18.6", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", + "fixed_version_range": "vers:maven/2.21.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", + "fixed_version_range": "vers:maven/3.1.0", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/<=2.18.5", + "fixed_version_range": "vers:maven/2.18.6", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", + "fixed_version_range": "vers:maven/2.21.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", + "fixed_version_range": "vers:maven/3.1.0", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + } + ], + "references": [ + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/commit/b0c428e6f993e1b5ece5c1c3cb2523e887cd52cf" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/pull/1555" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/security/advisories/GHSA-72hv-8253-57qq" + } + ], + "severities": [ + { + "system": "cvssv4", + "value": "6.9", + "scoring_elements": "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" + }, + { + "system": "generic_textual", + "value": "MODERATE", + "scoring_elements": "" + } + ], + "weaknesses": [ + 770 + ], + "patches": [], + "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/02/GHSA-72hv-8253-57qq/GHSA-72hv-8253-57qq.json", + "date_published": "2026-02-28 02:01:05+00:00" + } +] \ No newline at end of file diff --git a/vulnerabilities/utils.py b/vulnerabilities/utils.py index a4fe0274f..fd3476ece 100644 --- a/vulnerabilities/utils.py +++ b/vulnerabilities/utils.py @@ -691,6 +691,36 @@ def compute_content_id(advisory_data): return content_id +def get_normalized_advisory_v2(advisory_data): + """ + Return a normalized dictionary of advisory data for diffing history. + + :param advisory_data: An AdvisoryV2 object + :return: A normalized dictionary of advisory data + """ + from vulnerabilities.models import AdvisoryV2 + + if not isinstance(advisory_data, AdvisoryV2): + raise ValueError("Unsupported advisory data type for history diffing") + + advisory_data = advisory_data.to_advisory_data() + + normalized_data = { + "advisory_id": advisory_data.advisory_id, + "aliases": advisory_data.aliases, + "summary": advisory_data.summary, + "affected_packages": [pkg.to_dict() for pkg in advisory_data.affected_packages if pkg], + "references": [ref.to_dict() for ref in advisory_data.references if ref], + "severities": [sev.to_dict() for sev in advisory_data.severities if sev], + "weaknesses": advisory_data.weaknesses, + "patches": [patch.to_dict() for patch in advisory_data.patches], + "url": advisory_data.url, + # "date_published": str(advisory_data.date_published) if advisory_data.date_published else None, + } + + return normalized_data + + def compute_content_id_v2(advisory_data): """ Compute a unique content_id for an advisory by normalizing its data and hashing it. @@ -1129,3 +1159,72 @@ def safe_altcha_redirect(next_url: str) -> redirect: return redirect(next_url) return redirect("/") + + +def exclude_epss_severities(severities: list) -> list: + """ + Return a copy of the severities list with all EPSS entries removed. + EPSS scores are updated daily and can be tracked separately in EPSS tab. + """ + return [ + severity + for severity in (severities or []) + if str(severity.get("system", "")).lower() != "epss" + ] + + +def diff_advisories_v2(old_data: dict, new_data: dict) -> dict: + """ + Diff two normalised advisories and return the changes. + """ + changes = {} + all_keys = set(old_data.keys()).union(new_data.keys()) + + for field in all_keys: + old_field_value = old_data.get(field) + new_field_value = new_data.get(field) + + if isinstance(old_field_value, list) or isinstance(new_field_value, list): + old_items_list = old_field_value or [] + new_items_list = new_field_value or [] + + if field == "severities": + old_items_list = exclude_epss_severities(old_items_list) + new_items_list = exclude_epss_severities(new_items_list) + + if all(isinstance(item, str) for item in old_items_list + new_items_list): + # "aliases", "weaknesses" + old_items_set = set(old_items_list) + new_items_set = set(new_items_list) + if old_items_set != new_items_set: + changes[field] = { + "added": sorted(new_items_set - old_items_set), + "removed": sorted(old_items_set - new_items_set), + } + elif all(isinstance(item, dict) for item in old_items_list + new_items_list): + # "references", "affected_packages", "patches", "severities" + old_items_set = { + json.dumps(item, sort_keys=True, default=str) for item in old_items_list + } + new_items_set = { + json.dumps(item, sort_keys=True, default=str) for item in new_items_list + } + if old_items_set != new_items_set: + changes[field] = { + "added": [ + json.loads(item) for item in sorted(new_items_set - old_items_set) + ], + "removed": [ + json.loads(item) for item in sorted(old_items_set - new_items_set) + ], + } + elif ( + isinstance(old_field_value, str) + or isinstance(new_field_value, str) + or (old_field_value is None and new_field_value is None) + ): + # "url", "date_published", "summary" + if old_field_value != new_field_value: + changes[field] = {"old": old_field_value, "new": new_field_value} + + return changes diff --git a/vulnerabilities/views.py b/vulnerabilities/views.py index abebb4f1c..94a856adb 100644 --- a/vulnerabilities/views.py +++ b/vulnerabilities/views.py @@ -21,6 +21,7 @@ from django.core.cache import cache from django.core.exceptions import ValidationError from django.core.mail import send_mail +from django.core.paginator import Paginator from django.db.models import Exists from django.db.models import OuterRef from django.db.models import Prefetch @@ -63,7 +64,9 @@ from vulnerabilities.tasks import compute_queue_load_factor from vulnerabilities.throttling import AnonUserUIThrottle from vulnerabilities.utils import TYPES_WITH_MULTIPLE_IMPORTERS +from vulnerabilities.utils import diff_advisories_v2 from vulnerabilities.utils import get_advisories_from_groups +from vulnerabilities.utils import get_normalized_advisory_v2 from vulnerabilities.utils import safe_altcha_redirect from vulnerablecode import __version__ as VULNERABLECODE_VERSION from vulnerablecode.settings import env @@ -580,6 +583,65 @@ def get_context_data(self, **kwargs): return context +def build_advisory_history(advisory, page_number): + """ + Build advisory history for a given advisory. + """ + advisory_history_qs = ( + models.AdvisoryV2.objects.filter(avid=advisory.avid) + .order_by("-date_collected", "-unique_content_id") + .prefetch_related( + "aliases", "references", "weaknesses", "severities", "impacted_packages", "patches" + ) + ) + + paginator = Paginator(advisory_history_qs, 10) + pagination_obj = paginator.get_page(page_number) + advisories_on_page = list(pagination_obj.object_list) + + last_advisory_fallback = None + if pagination_obj.has_next(): + # Fetch the very first record from the next page to calculate the diff for the last item on the current page + last_advisory_fallback = advisory_history_qs.filter( + date_collected__lt=advisories_on_page[-1].date_collected + ).first() + + normalized_advisories = { + adv.unique_content_id: get_normalized_advisory_v2(adv) for adv in advisories_on_page + } + + # Normalize the last advisory fallback + if last_advisory_fallback: + normalized_advisories[last_advisory_fallback.unique_content_id] = ( + get_normalized_advisory_v2(last_advisory_fallback) + ) + + advisory_history = [] + for current_index, current_advisory in enumerate(advisories_on_page): + # Diff w.r.t immediately older advisory + if current_index + 1 < len(advisories_on_page): + older_advisory = advisories_on_page[current_index + 1] + else: + older_advisory = last_advisory_fallback + + current_data = normalized_advisories[current_advisory.unique_content_id] + older_data = ( + normalized_advisories[older_advisory.unique_content_id] if older_advisory else None + ) + + advisory_history.append( + { + "date_collected": current_advisory.date_collected, + "unique_content_id": current_advisory.unique_content_id, + "is_latest": current_advisory.is_latest, + "is_initial": older_advisory is None, + "diff": diff_advisories_v2(older_data, current_data) if older_data else {}, + } + ) + + return advisory_history, pagination_obj + + class AdvisoryDetails(VulnerableCodeDetailView): model = models.AdvisoryV2 template_name = "advisory_detail.html" @@ -647,6 +709,12 @@ def get_queryset(self): "source_advisory__url", ), ), + Prefetch( + "impacted_packages", + queryset=models.ImpactedPackage.objects.only( + "base_purl", "affecting_vers", "fixed_vers" + ), + ), ) ) @@ -738,6 +806,10 @@ def add_ssvc(ssvc): for ssvc in advisory.related_ssvcs.all(): add_ssvc(ssvc) + advisory_history, pagination_obj = build_advisory_history( + advisory, self.request.GET.get("page", 1) + ) + context["ssvcs"] = ssvc_entries context.update( { @@ -750,11 +822,29 @@ def add_ssvc(ssvc): "weaknesses": weaknesses_present_in_db, "status": advisory.get_status_label, "epss_data": epss_data, + "advisory_history": advisory_history, + "page_obj": pagination_obj, + "is_snapshot": False, } ) return context +class AdvisorySnapshotView(AdvisoryDetails): + def get_object(self, queryset=None): + avid = self.kwargs["avid"] + uid = self.kwargs["unique_content_id"] + try: + return self.get_queryset().get(avid=avid, unique_content_id=uid) + except models.AdvisoryV2.DoesNotExist: + raise Http404 + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({"advisory_history": [], "is_snapshot": True}) + return context + + class HomePage(VulnerableCodeView): template_name = "index.html" @@ -993,6 +1083,46 @@ def get_queryset(self): ) +class AdvisoryPackagesSnapshotView(AdvisoryPackagesDetails): + """ + View to display package details for an advisory snapshot. + """ + + def get_object(self, queryset=None): + avid = self.kwargs["avid"] + uid = self.kwargs["unique_content_id"] + try: + return self.get_queryset().get(avid=avid, unique_content_id=uid) + except models.AdvisoryV2.DoesNotExist: + raise Http404 + + def get_queryset(self): + from vulnerabilities.models import ImpactedPackage + from vulnerabilities.models import PackageV2 + + + return AdvisoryV2.objects.all().prefetch_related( + Prefetch( + "impacted_packages", + queryset=ImpactedPackage.objects.all().prefetch_related( + Prefetch( + "affecting_packages", + queryset=PackageV2.objects.only("type", "namespace", "name", "version"), + ), + Prefetch( + "fixed_by_packages", + queryset=PackageV2.objects.only("type", "namespace", "name", "version"), + ), + ), + ) + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({"is_snapshot": True}) + return context + + class PipelineScheduleListView(VulnerableCodeListView, FormMixin): model = PipelineSchedule context_object_name = "schedule_list" diff --git a/vulnerablecode/static/css/custom.css b/vulnerablecode/static/css/custom.css index 6d8918a8f..b914ccf55 100644 --- a/vulnerablecode/static/css/custom.css +++ b/vulnerablecode/static/css/custom.css @@ -655,3 +655,63 @@ ul.fixed_by_bullet li li li { box-shadow: none; } } + + +/* Diff formatting for advisory history */ + +.diff-badge { + padding: 2px 6px; + border-radius: 4px; + display: inline-block; +} + +.diff-badge-yellow { + color: #856404; + background-color: #fff3cd; +} + +.diff-badge-red { + color: #721c24; + background-color: #f8d7da; +} + +.diff-badge-green { + color: #155724; + background-color: #d4edda; +} + +.diff-wrap { + word-break: break-word; + white-space: pre-wrap; +} + +.diff-max-width { + max-width: 600px; +} + +#tab-content table div { + display: block !important; + margin-bottom: 0 !important; + padding-bottom: 0 !important; +} + +.history-diff-collapsible { + max-height: 120px; + overflow: hidden; + position: relative; +} + +.history-diff-collapsible:not(.is-expanded):not(.no-truncate)::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 40px; + background: linear-gradient(transparent, white); + pointer-events: none; +} + +.history-diff-collapsible.is-expanded { + max-height: none; +} \ No newline at end of file diff --git a/vulnerablecode/urls.py b/vulnerablecode/urls.py index f8c97f8e2..9bdd6159c 100644 --- a/vulnerablecode/urls.py +++ b/vulnerablecode/urls.py @@ -35,6 +35,8 @@ from vulnerabilities.views import AdvisoryPackageCommitPatchDetails from vulnerabilities.views import AdvisoryPackageCurationView from vulnerabilities.views import AdvisoryPackagesDetails +from vulnerabilities.views import AdvisoryPackagesSnapshotView +from vulnerabilities.views import AdvisorySnapshotView from vulnerabilities.views import AdvisoryToDoListView from vulnerabilities.views import AffectedByAdvisoriesListView from vulnerabilities.views import AltchaView @@ -130,6 +132,11 @@ def __init__(self, *args, **kwargs): HomePageV2.as_view(), name="home", ), + path( + "advisories/packages/snapshot//", + AdvisoryPackagesSnapshotView.as_view(), + name="advisory_package_snapshot", + ), path( "advisories/packages/", AdvisoryPackagesDetails.as_view(), @@ -140,6 +147,11 @@ def __init__(self, *args, **kwargs): AdvisoryPackageCommitPatchDetails.as_view(), name="advisory_package_commit_details", ), + path( + "advisories/snapshot//", + AdvisorySnapshotView.as_view(), + name="advisory_snapshot", + ), path( "advisories/", AdvisoryDetails.as_view(),