Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
884 changes: 527 additions & 357 deletions vulnerabilities/templates/advisory_detail.html

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions vulnerabilities/templates/advisory_package_details.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@

{% if advisoryv2 %}
<section class="section pt-4">
{% if is_snapshot %}
<div class="notification is-warning mb-3 has-text-centered" style="max-width: fit-content; margin: 0 auto;">
<i class="fa fa-history mr-1"></i> Historical snapshot collected on <strong>{{ advisoryv2.date_collected|date:"Y-m-d H:i" }} UTC</strong>.
<a href="{% url 'advisory_package_details' avid=advisoryv2.avid %}" class="ml-3"><strong>View current version</strong> <i class="fa fa-arrow-right is-size-7"></i></a>
</div>
{% endif %}

<div class="details-container">
<article class="panel is-info panel-header-only">
<div class="panel-heading py-2 is-size-6">
Expand Down
118 changes: 118 additions & 0 deletions vulnerabilities/templatetags/diff_advisory_history.py
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions vulnerabilities/tests/test_advisory_history.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
]
Loading