CVE-2025-60249
Description
vulnerability-lookup 2.16.0 allows XSS in bundle.py, comment.py, and user.py, by a user on a vulnerability-lookup instance who can add bundles, comments, or sightings. A cross-site scripting (XSS) vulnerability was discovered in the handling of user-supplied input in the Bundles, Comments, and Sightings components. Untrusted data was not properly sanitized before being rendered in templates and tables, which could allow attackers to inject arbitrary JavaScript into the application. The issue was due to unsafe use of innerHTML and insufficient validation of dynamic URLs and model fields. This vulnerability has been fixed by escaping untrusted data, replacing innerHTML assignments with safer DOM methods, encoding URLs with encodeURIComponent, and improving input validation in the affected models.
Affected products
1- Range: v0.5.0, v0.6.0, v0.7.0, …
Patches
1afa12347f146fix: [security] sanitize user input in comments, bundles, and sightings
13 files changed · +580 −310
website/lib/constants.py+1 −1 modified@@ -32,7 +32,7 @@ } ALLOWED_ATTRIBUTES: dict[str, list[str]] = { - "a": ["href", "title", "rel", "target"], + "a": ["href", "title"], "img": ["src", "alt", "title"], }
website/lib/sanitizers.py+15 −0 added@@ -0,0 +1,15 @@ +import html as _html + +import bleach # type: ignore[import-untyped] + +from website.lib.constants import ALLOWED_ATTRIBUTES, ALLOWED_TAGS + + +def sanitize_html_fragment(raw_html: str) -> str: + """Clean an HTML fragment of malicious content and return it.""" + return bleach.clean(raw_html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES) + + +def sanitize_text(raw_text: str) -> str: + cleaned = bleach.clean(raw_text, tags=[], attributes={}, strip=True) + return _html.escape(cleaned)
website/lib/utils.py+0 −7 modified@@ -5,12 +5,10 @@ from threading import Thread from typing import Any, Callable, List -import bleach # type: ignore[import-untyped] import requests from sqlalchemy import desc, func from vulnerabilitylookup.default import get_homedir -from website.lib.constants import ALLOWED_ATTRIBUTES, ALLOWED_TAGS from website.models import Bundle, Comment, User from website.web.bootstrap import application, db @@ -30,11 +28,6 @@ def wrapper(*args: Any, **kwargs: Any) -> None: return wrapper -def sanitize_html_fragment(raw_html: str) -> str: - """Clean an HTML fragment of malicious content and return it.""" - return bleach.clean(raw_html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES) - - def allowed_file(filename: str) -> bool: """ Check if the uploaded file is allowed.
website/models/bundle.py+26 −1 modified@@ -6,6 +6,7 @@ from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import validates +from website.lib.sanitizers import sanitize_html_fragment, sanitize_text from website.web.bootstrap import db @@ -36,7 +37,8 @@ class Bundle(db.Model): # type: ignore[name-defined, misc] def validates_name(self, key: str, value: str) -> str: assert 3 <= len(value) <= 256, AssertionError("Maximum length for name: 256") value = value.strip() - return value + cleaned = sanitize_text(value) + return cleaned @validates("description_format") def validates_description_format(self, key: str, value: str) -> str: @@ -45,6 +47,29 @@ def validates_description_format(self, key: str, value: str) -> str: ) return value.lower() + @validates("description") + def validates_description(self, key: str, value: str) -> str: + """ + Sanitize description at storage time: + - if description_format == 'text' -> treat as plain text: strip tags and escape + - if description_format == 'markdown' -> strip unsafe HTML but keep a safe subset + """ + # Note: SQLAlchemy validators receive the *raw* value but may not see description_format + # yet depending on order of attribute assignments. To be safe, we check self.description_format, + # but fall back to 'markdown' if unknown. + fmt = getattr(self, "description_format", None) or "markdown" + value = value or "" + value = value.strip() + + if fmt == "text": + return sanitize_text(value) + else: + # markdown: allow markdown syntax but remove unsafe embedded HTML. + cleaned = sanitize_html_fragment(value) + # Optionally, also linkify safe URLs: + # cleaned = bleach.linkify(cleaned) + return cleaned + def as_json(self) -> str: return json.dumps( {
website/models/comment.py+26 −1 modified@@ -6,6 +6,7 @@ from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import validates +from website.lib.sanitizers import sanitize_html_fragment, sanitize_text from website.web.bootstrap import db @@ -39,7 +40,8 @@ class Comment(db.Model): # type: ignore[name-defined, misc] def validates_title(self, key: str, value: str) -> str: assert 3 <= len(value) <= 256, AssertionError("Maximum length for title: 256") value = value.strip() - return value + cleaned = sanitize_text(value) + return cleaned @validates("description_format") def validates_description_format(self, key: str, value: str) -> str: @@ -48,6 +50,29 @@ def validates_description_format(self, key: str, value: str) -> str: ) return value.lower() + @validates("description") + def validates_description(self, key: str, value: str) -> str: + """ + Sanitize description at storage time: + - if description_format == 'text' -> treat as plain text: strip tags and escape + - if description_format == 'markdown' -> strip unsafe HTML but keep a safe subset + """ + # Note: SQLAlchemy validators receive the *raw* value but may not see description_format + # yet depending on order of attribute assignments. To be safe, we check self.description_format, + # but fall back to 'markdown' if unknown. + fmt = getattr(self, "description_format", None) or "markdown" + value = value or "" + value = value.strip() + + if fmt == "text": + return sanitize_text(value) + else: + # markdown: allow markdown syntax but remove unsafe embedded HTML. + cleaned = sanitize_html_fragment(value) + # Optionally, also linkify safe URLs: + # cleaned = bleach.linkify(cleaned) + return cleaned + def as_json(self) -> str: return json.dumps( {
website/models/sighting.py+39 −3 modified@@ -1,4 +1,5 @@ import json +import re import uuid from typing import Any @@ -7,6 +8,22 @@ from sqlalchemy.orm import validates from website.web.bootstrap import db +from website.lib.sanitizers import sanitize_text + + +vulnerability_patterns = re.compile( + r"\b(CVE-\d{4}-\d{4,})\b" # CVE pattern + r"|\b(GCVE-\d+-\d{4}-\d+)\b" # GCVE pattern + r"|\b(GHSA-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4})\b" # GHSA pattern + r"|\b(PYSEC-\d{4}-\d{2,5})\b" # PYSEC pattern + r"|\b(GSD-\d{4}-\d{4,5})\b" # GSD pattern + r"|\b(wid-sec-w-\d{4}-\d{4})\b" # CERT-Bund pattern + r"|\b(cisco-sa-\d{8}-[a-zA-Z0-9]+)\b" # CISCO pattern + r"|\b(RHSA-\d{4}:\d{4})\b" # RedHat pattern + r"|\b(msrc_CVE-\d{4}-\d{4,})\b" # MSRC CVE pattern + r"|\b(CERTFR-\d{4}-[A-Z]{3}-\d{3})\b", # CERT-FR pattern + re.IGNORECASE, +) class Sighting(db.Model): # type: ignore[name-defined, misc] @@ -38,18 +55,37 @@ class Sighting(db.Model): # type: ignore[name-defined, misc] author_id = db.Column(db.Integer(), db.ForeignKey("user.id"), nullable=False) @validates("type") - def validates_title(self, key: str, value: str) -> str: - assert value in [ + def validate_type(self, key: str, value: str) -> str: + allowed_types = { "seen", "exploited", "not-exploited", "confirmed", "not-confirmed", "patched", "not-patched", - ], AssertionError("Unsupported sighting type.") + } + if value not in allowed_types: + raise ValueError(f"Unsupported sighting type: {value}") return value + @validates("vulnerability") + def validate_vulnerability(self, key: str, value: str | None) -> str | None: + if value is None: + return value + if not vulnerability_patterns.match(value): + raise ValueError(f"Invalid vulnerability identifier: {value}") + return value + + @validates("source") + def validate_source(self, key: str, value: str) -> str: + if not value: + return "" + value = value.strip() + if len(value) > 2048: + raise ValueError("Source must not exceed 2048 characters") + return sanitize_text(value) + def as_json(self) -> str: return json.dumps( {
website/web/templates/bundles/bundle.html+27 −9 modified@@ -85,35 +85,53 @@ <h2 id="combined-sightings">Combined sightings</h2> function addRowToTable(sighting) { const row = document.createElement("tr"); + // Author const authorCell = document.createElement("td"); - authorCell.textContent = sighting.author.login; + authorCell.textContent = sighting.author.login || ""; row.appendChild(authorCell); + // Vulnerability (safe link) const vulnerabilityCell = document.createElement("td"); - vulnerabilityCell.innerHTML = '<a href="/vuln/'+sighting.vulnerability+'">'+sighting.vulnerability.toUpperCase()+'</a>';; + if (sighting.vulnerability) { + const vulnLink = document.createElement("a"); + vulnLink.href = "/vuln/" + encodeURIComponent(sighting.vulnerability); + vulnLink.textContent = sighting.vulnerability.toUpperCase(); + vulnerabilityCell.appendChild(vulnLink); + } row.appendChild(vulnerabilityCell); + // Source (safe link if URL) const sourceCell = document.createElement("td"); - if (isValidURL(sighting.source)) { - sourceCell.innerHTML = '<a href="'+sighting.source+'" rel="noreferrer" target="_blank">'+sighting.source+'</a>'; + if (sighting.source && isValidURL(sighting.source)) { + const srcLink = document.createElement("a"); + srcLink.href = sighting.source; + srcLink.rel = "noreferrer noopener"; + srcLink.target = "_blank"; + srcLink.textContent = sighting.source; + sourceCell.appendChild(srcLink); } else { - sourceCell.textContent = sighting.source; + sourceCell.textContent = sighting.source || ""; } row.appendChild(sourceCell); + // Type const typeCell = document.createElement("td"); - typeCell.textContent = sighting.type; + typeCell.textContent = sighting.type || ""; row.appendChild(typeCell); + // Date const dateCell = document.createElement("td"); - dateCell.classList.add('datetime'); - dateCell.textContent = sighting.creation_timestamp; - dateCell.title = sighting.creation_timestamp; + dateCell.classList.add("datetime"); + if (sighting.creation_timestamp) { + dateCell.textContent = sighting.creation_timestamp; + dateCell.title = sighting.creation_timestamp; + } row.appendChild(dateCell); tableBody.appendChild(row); } + // Fetch bundle data fetch(`/api/bundle/?uuid=${uuid}`) .then(response => response.json())
website/web/templates/bundles/bundles.html+61 −47 modified@@ -27,60 +27,74 @@ <h1>Recent bundles</h1> </div> </div> <script> - document.addEventListener("DOMContentLoaded", function() { - var DateTime = luxon.DateTime; - var converter = new showdown.Converter({tables: true}); - var bundleTemplate = _.template( - '<div class="card markdown-description">' + - '<div class="card-body">' + - '<h5 class="card-title"><a href="/bundle/<%= uuid %>"><%= name %></a></h5>' + - '<h6 class="card-subtitle mb-2 text-body-secondary"><%= timestamp %> by <a href="/user/<%= author_login %>"><%= author_name %></a></h6>' + - '<p class="card-text"><%= description %></p>' + - 'Related vulnerabilities: <% _.forEach(related_vulnerabilities, function(vuln) ' + - '{ %><a href="/vuln/<%= vuln %>"><%- vuln %></a> <% }); %><br />' + - '</div>'); - fetch("{{ url_for('apiv1.bundle_bundles_list') }}") +document.addEventListener("DOMContentLoaded", () => { + const DateTime = luxon.DateTime; + const converter = new showdown.Converter({ tables: true }); + + const bundleTemplate = _.template(` + <div class="card markdown-description"> + <div class="card-body"> + <h5 class="card-title"> + <a href="/bundle/<%- uuid %>"><%- name %></a> + </h5> + <h6 class="card-subtitle mb-2 text-body-secondary"> + <%- timestamp %> by <a href="/user/<%- author_login %>"><%- author_name %></a> + </h6> + <p class="card-text"><%= description %></p> + <p> + Related vulnerabilities: + <% _.forEach(related_vulnerabilities, vuln => { %> + <a href="/vuln/<%- vuln %>"><%- vuln %></a> + <% }); %> + </p> + </div> + </div> + `); + + const listEl = document.getElementById("list-bundles"); + + fetch("{{ url_for('apiv1.bundle_bundles_list') }}") .then(response => response.json()) .then(result => { - document.getElementById("list-bundles").innerHTML = ""; - if (result.metadata.count == 0) { - document.getElementById("list-bundles").innerHTML = "<p>No bundle.</p>"; - } - result.data - .sort(function (a, b) { - return new Date(b.updated_at) - new Date(a.updated_at); - }) - .map(function (bundle) { - var author = bundle.author - delete bundle.author; + listEl.innerHTML = ""; + + if (result.metadata.count === 0) { + listEl.innerHTML = `<p>No bundle.</p>`; + return; + } - var description = converter.makeHtml(bundle.description); - description = linkifySecurityIdentifiers(description); + result.data + .sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at)) + .forEach(bundle => { + const author = bundle.author || { name: "unknown", login: "unknown" }; - var cardHTML = bundleTemplate({ - 'uuid': bundle.uuid, - 'name': bundle.name, - 'description': description, - 'timestamp': DateTime.fromISO(bundle.timestamp).toRelative(), - 'related_vulnerabilities': bundle.related_vulnerabilities.map(v => v.toLowerCase()), - 'author_name': author.name, - 'author_login': author.login + let description = converter.makeHtml(bundle.description || ""); + description = linkifySecurityIdentifiers(description); + + const cardHTML = bundleTemplate({ + uuid: bundle.uuid, + name: bundle.name, + description: description, + timestamp: DateTime.fromISO(bundle.timestamp).toRelative(), + related_vulnerabilities: (bundle.related_vulnerabilities || []).map(v => v.toLowerCase()), + author_name: author.name, + author_login: author.login, + }); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = cardHTML; + + // Append safely + listEl.appendChild(wrapper.firstElementChild); + listEl.appendChild(document.createElement("br")); }); - var element = document.createElement("div"); - var element_br = document.createElement("br"); - element.innerHTML = cardHTML; - document.getElementById("list-bundles").appendChild(element.firstChild); - document.getElementById("list-bundles").append(element_br); - }) }) - .then(_ => { - setTimeout(() => { - formatMarkdownOutput(); - }, 0); // 0ms delay still allows the browser to update the DOM + .then(() => { + setTimeout(formatMarkdownOutput, 0); // let DOM update }) - .catch((error) => { - console.error('Error:', error); + .catch(err => { + console.error("Error while loading bundles:", err); }); - }); +}); </script> {% endblock %}
website/web/templates/comments/comments.html+90 −54 modified@@ -76,66 +76,102 @@ <h1>Recent comments</h1> load_comments("meta=%5B%7B%22tags%22%3A%20%5B%22vulnerability:information=PoC%22%5D%7D%5D"); }; + function load_comments(parameters){ - var DateTime = luxon.DateTime; - var converter = new showdown.Converter({tables: true}); - var commentTemplate = _.template( - '<div class="card markdown-description">' + - '<div class="card-body">' + - '<h5 class="card-title"><a href="/comment/<%= uuid %>"><%= title %></a> on <%= vulnerability_id %></h5>' + - '<h6 class="card-subtitle mb-2 text-body-secondary"><%= timestamp %> by <a href="/user/<%= author_login %>"><%= author_name %></a></h6>' + - '<p class="card-text"><%= description %></p>' + - '<a role="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="collapse" data-bs-target="#collapseJsonComment<%= uuid %>" aria-expanded="false" aria-controls="collapseJsonComment<%= uuid %>">JSON</a>' + - '<div class="collapse" id="collapseJsonComment<%= uuid %>"><br /><pre class="json-container"><%= comment %></pre></div><hr />' + - '<a class="btn btn-primary" href="/vuln/<%= vulnerability_id %>"><%= vulnerability_id %></a>' + - '</div>'); - url = "{{ url_for('apiv1.comment_comments_list') }}" - if (url != "") { + const DateTime = luxon.DateTime; + const converter = new showdown.Converter({tables: true}); + + // escape interpolation by default (<%- ... %>) + const cardTemplate = _.template( + '<div class="card markdown-description">' + + '<div class="card-body">' + + '<h5 class="card-title"></h5>' + + '<h6 class="card-subtitle mb-2 text-body-secondary"></h6>' + + '<p class="card-text"></p>' + + '<a role="button" class="btn btn-primary dropdown-toggle" ' + + 'data-bs-toggle="collapse" ' + + 'aria-expanded="false">JSON</a>' + + '<div class="collapse"><br /><pre class="json-container"></pre></div><hr />' + + '<a class="btn btn-primary vuln-link"></a>' + + '</div>' + + '</div>' + ); + + let url = "{{ url_for('apiv1.comment_comments_list') }}"; + if (url) { url = url + "?" + parameters; } + fetch(url) - .then(response => response.json()) - .then(result => { - document.getElementById("list-comments").innerHTML = ""; - if (result.metadata.count == 0) { - document.getElementById("list-comments").innerHTML = "<p>No comment.</p>"; - } - result.data - .sort(function (a, b) { - return new Date(b.updated_at) - new Date(a.updated_at); + .then(response => response.json()) + .then(result => { + const list = document.getElementById("list-comments"); + list.innerHTML = ""; + + if (result.metadata.count === 0) { + list.textContent = "No comment."; + return; + } + + result.data + .sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at)) + .forEach(comment => { + const author = comment.author; + delete comment.author; + + // Convert markdown → HTML and sanitize + let descriptionHtml = converter.makeHtml(comment.description); + descriptionHtml = linkifySecurityIdentifiers(descriptionHtml); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = cardTemplate(); // empty shell + + const card = wrapper.firstChild; + const h5 = card.querySelector("h5.card-title"); + const h6 = card.querySelector("h6.card-subtitle"); + const p = card.querySelector("p.card-text"); + const pre = card.querySelector("pre.json-container"); + const vulnLink = card.querySelector("a.vuln-link"); + const collapse = card.querySelector("div.collapse"); + const collapseBtn = card.querySelector("a.dropdown-toggle"); + + // Safe text insertion + const link = document.createElement("a"); + link.href = "/comment/" + comment.uuid; + link.textContent = comment.title; // plain text only + h5.appendChild(link); + h5.insertAdjacentText("beforeend", " on " + comment.vulnerability.toLowerCase()); + + h6.textContent = DateTime.fromISO(comment.timestamp).toRelative() + + " by "; + const authorLink = document.createElement("a"); + authorLink.href = "/user/" + author.login; + authorLink.textContent = author.name; + h6.appendChild(authorLink); + + // description: allowed HTML, but sanitized with DOMPurify + p.innerHTML = descriptionHtml; + + // JSON pretty print + pre.textContent = JSON.stringify(comment, null, 2); + + // collapse id must be unique + const collapseId = "collapseJsonComment" + comment.uuid; + collapse.id = collapseId; + collapseBtn.setAttribute("data-bs-target", "#" + collapseId); + collapseBtn.setAttribute("aria-controls", collapseId); + + vulnLink.href = "/vuln/" + comment.vulnerability.toLowerCase(); + vulnLink.textContent = comment.vulnerability.toLowerCase(); + + list.appendChild(card); + list.appendChild(document.createElement("br")); + }); }) - .map(function (comment) { - var author = comment.author - delete comment.author; - - var description = converter.makeHtml(comment.description); - description = linkifySecurityIdentifiers(description); - - var cardHTML = commentTemplate({ - 'comment': prettyPrintJson.toHtml(JSON.parse(JSON.stringify(comment, null, 2))), - 'uuid': comment.uuid, - 'title': comment.title, - 'description': description, - 'timestamp': DateTime.fromISO(comment.timestamp).toRelative(), - 'vulnerability_id': comment.vulnerability.toLowerCase(), - 'author_name': author.name, - 'author_login': author.login, - }); - var element = document.createElement("div"); - var element_br = document.createElement("br"); - element.innerHTML = cardHTML; - document.getElementById("list-comments").appendChild(element.firstChild); - document.getElementById("list-comments").append(element_br); + .then(() => { + setTimeout(formatMarkdownOutput, 0); }) - }) - .then(_ => { - setTimeout(() => { - formatMarkdownOutput(); - }, 0); // 0ms delay still allows the browser to update the DOM - }) - .catch((error) => { - console.error('Error:', error); - }); + .catch(err => console.error("Error:", err)); } </script> {% endblock %}
website/web/templates/vuln.html+290 −183 modified@@ -816,86 +816,132 @@ <h3>Nomenclature</h3> } function loadComments() { - COMMENTS = {}; - var DateTime = luxon.DateTime; - var converter = new showdown.Converter({tables: true, moreStyling: true}); - var commentTemplate = _.template( - '<div class="card markdown-description">' + - '<div class="card-body">' + - '<h5 class="card-title"><a href="/comment/<%= uuid %>"><%= title %></a></h5>' + - '<p class="card-title">' + - '<% _.forEach(tags, function(tag) ' + - '{ %><span class="badge bg-primary"><a class="link-light" href="/comments/?meta=%5B%7B%22tags%22%3A%20%5B%22<%= tag %>%22%5D%7D%5D"><%= tag %></a></span> <% }); %>' + - '</p>' + - '<h6 class="card-subtitle mb-2 text-body-secondary"><%= timestamp %> by <a href="/user/<%= author_login %>"><%= author_name %></a></h6>' + - '<p class="card-text"><%= description %></p>' + - '<div class="btn-group" role="group">' + - '<a role="button" class="btn btn-primary" data-bs-toggle="collapse" data-bs-target="#collapseJsonComment<%= uuid %>" aria-expanded="false" aria-controls="collapseJsonComment<%= uuid %>">JSON</a>' + - '</div>' + - '<div class="collapse" id="collapseJsonComment<%= uuid %>"><br /><pre class="json-container"><%= comment %></pre></div>' + - '</div></div>' - ); - fetch("{{ url_for('apiv1.comment_comments_list', vuln_id=vulnerability_id) }}") + COMMENTS = {}; + const DateTime = luxon.DateTime; + const converter = new showdown.Converter({tables: true, moreStyling: true}); + + // Skeleton template (no untrusted interpolation here) + const cardTemplate = ` + <div class="card markdown-description"> + <div class="card-body"> + <h5 class="card-title"></h5> + <p class="card-title tags"></p> + <h6 class="card-subtitle mb-2 text-body-secondary"></h6> + <p class="card-text"></p> + <div class="btn-group" role="group"> + <a role="button" class="btn btn-primary dropdown-toggle" + data-bs-toggle="collapse" + aria-expanded="false">JSON</a> + </div> + <div class="collapse"><br /><pre class="json-container"></pre></div> + </div> + </div> + `; + + fetch("{{ url_for('apiv1.comment_comments_list', vuln_id=vulnerability_id) }}") .then(response => response.json()) .then(result => { - document.getElementById("list-comments").innerHTML = ""; + const list = document.getElementById("list-comments"); + list.innerHTML = ""; document.getElementById("nb-comments").innerText = result.metadata.count; - if (result.metadata.count == 0) { - document.getElementById("list-comments").innerHTML = "<p>No comment for this vulnerability. Browse <a href='/comments'>all the comments</a>.</p>"; + + if (result.metadata.count === 0) { + list.innerHTML = + "No comment for this vulnerability. Browse " + + "<a href='/comments'>all the comments</a>."; + return; } + result.data - .sort(function (a, b) { - return new Date(b.timestamp) - new Date(a.timestamp); - }) - .map(function (comment) { - var author = comment.author - delete comment.author; - if (Array.isArray(comment["meta"]) && comment["meta"].length > 0) { - var itemWithTags = comment.meta.find(item => item.tags); - var tags = itemWithTags ? itemWithTags.tags : []; - } else { - var tags = []; - } - var cardHTML = commentTemplate({ - 'comment': JSON.stringify(comment, null, 2), - 'uuid': comment.uuid, - 'title': comment.title, - 'description': converter.makeHtml(comment.description), - 'timestamp': DateTime.fromISO(comment.timestamp).toRelative(), - 'author_name': author.name, - 'author_login': author.login, - 'tags': tags + .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) + .forEach(comment => { + const author = comment.author; + delete comment.author; + + let tags = []; + if (Array.isArray(comment.meta) && comment.meta.length > 0) { + const itemWithTags = comment.meta.find(item => item.tags); + tags = itemWithTags ? itemWithTags.tags : []; + } + + // Markdown → HTML → sanitize + let descriptionHtml = converter.makeHtml(comment.description); + descriptionHtml = linkifySecurityIdentifiers(descriptionHtml); + + // Build DOM from safe skeleton + const wrapper = document.createElement("div"); + wrapper.innerHTML = cardTemplate; + const card = wrapper.firstElementChild; + + // Title + const h5 = card.querySelector("h5.card-title"); + const titleLink = document.createElement("a"); + titleLink.href = "/comment/" + comment.uuid; + titleLink.textContent = comment.title; + h5.appendChild(titleLink); + + // Tags + const tagContainer = card.querySelector("p.tags"); + tags.forEach(tag => { + const span = document.createElement("span"); + span.className = "badge bg-primary me-1"; + const link = document.createElement("a"); + link.className = "link-light"; + link.href = "/comments/?meta=" + encodeURIComponent(JSON.stringify([{tags: [tag]}])); + link.textContent = tag; + span.appendChild(link); + tagContainer.appendChild(span); + }); + + // Subtitle + const h6 = card.querySelector("h6.card-subtitle"); + h6.textContent = DateTime.fromISO(comment.timestamp).toRelative() + " by "; + const authorLink = document.createElement("a"); + authorLink.href = "/user/" + author.login; + authorLink.textContent = author.name; + h6.appendChild(authorLink); + + // Description + const p = card.querySelector("p.card-text"); + p.innerHTML = descriptionHtml; + + // JSON + const pre = card.querySelector("pre.json-container"); + pre.textContent = JSON.stringify(comment, null, 2); + + // Collapse handling + const collapseId = "collapseJsonComment" + comment.uuid; + const collapse = card.querySelector("div.collapse"); + const collapseBtn = card.querySelector("a.dropdown-toggle"); + collapse.id = collapseId; + collapseBtn.setAttribute("data-bs-target", "#" + collapseId); + collapseBtn.setAttribute("aria-controls", collapseId); + + COMMENTS[comment.uuid] = comment; + list.appendChild(card); + list.appendChild(document.createElement("br")); }); - COMMENTS[comment.uuid] = comment; - var element = document.createElement("div"); - var element_br = document.createElement("br"); - element.innerHTML = cardHTML; - document.getElementById("list-comments").appendChild(element.firstChild); - document.getElementById("list-comments").append(element_br); - }) }) .then(_ => { setTimeout(() => { formatMarkdownOutput(); - if (easyMDE === null) { + if (easyMDE === null) { easyMDE = new EasyMDE({ - element: document.getElementById('root[description]'), - autoRefresh: { delay: 300 }, - toolbarButtonClassPrefix: "mde", - toolbar: [ - "bold", "italic", "heading", "|", "quote", "code", "table", "unordered-list", "ordered-list", "|", "link", "image", "|", "preview", "side-by-side", "fullscreen", "|" - ] - }); + element: document.getElementById("root[description]"), + autoRefresh: { delay: 300 }, + toolbarButtonClassPrefix: "mde", + toolbar: [ + "bold", "italic", "heading", "|", "quote", "code", "table", + "unordered-list", "ordered-list", "|", "link", "image", "|", + "preview", "side-by-side", "fullscreen", "|" + ] + }); } - }, 0); // 0ms delay still allows the browser to update the DOM + }, 0); return COMMENTS; }) - .catch((error) => { - console.error('Error:', error); - }); - - + .catch(err => console.error("Error:", err)); } function initialize_editor(schema, json_object) { @@ -954,135 +1000,196 @@ <h3>Nomenclature</h3> }; function loadBundles() { - var DateTime = luxon.DateTime; - var converter = new showdown.Converter({tables: true, moreStyling: true}); - var bundleTemplate = _.template( - '<div class="card markdown-description">' + - '<div class="card-body">' + - '<h5 class="card-title"><a href="/bundle/<%= uuid %>"><%= name %></a></h5>' + - '<h6 class="card-subtitle mb-2 text-body-secondary"><%= timestamp %> by <a href="/user/<%= author_login %>"><%= author_name %></a></h6>' + - '<p class="card-text"><%= description %></p>' + - '<h5 class="card-text">Related vulnerabilities</h5>' + - '<div class="card" >' + - '<ul class="list-group list-group-flush">' + - '<% _.forEach(related_vulnerabilities, function(vuln) ' + - '{ %><li class="list-group-item"><a href="/vuln/<%= vuln %>"><%- vuln %></a></li><% }); %>' + - '</ul>' + - '</div>' + - '</div>'); + const DateTime = luxon.DateTime; + const converter = new showdown.Converter({ tables: true, moreStyling: true }); + fetch("{{ url_for('apiv1.bundle_bundles_list', vuln_id=vulnerability_id) }}") - .then(response => response.json()) - .then(result => { - document.getElementById("list-bundles").innerHTML = "<p>Bundles referring to this vulnerability.</p>"; - if (result.metadata.count == 0) { - document.getElementById("list-bundles").innerHTML = "<p>This vulnerability is not linked to any bundle.</p>"; - } - result.data - .sort(function (a, b) { - return new Date(b.updated_at) - new Date(a.updated_at); - }) - .map(function (bundle) { - var author = bundle.author - delete bundle.author; - var cardHTML = bundleTemplate({ - 'uuid': bundle.uuid, - 'name': bundle.name, - 'description': converter.makeHtml(bundle.description), - 'timestamp': DateTime.fromISO(bundle.timestamp).toRelative(), - 'related_vulnerabilities': bundle.related_vulnerabilities.map(v => v.toLowerCase()), - 'author_name': author.name, - 'author_login': author.login - }); - var element = document.createElement("div"); - var element_br = document.createElement("br"); - element.innerHTML = cardHTML; - document.getElementById("list-bundles").appendChild(element.firstChild); - document.getElementById("list-bundles").append(element_br); - }) - }) - .then(_ => { - setTimeout(() => { - formatMarkdownOutput(); - }, 0); // 0ms delay still allows the browser to update the DOM - }) - .catch((error) => { - console.error('Error:', error); - }); - }; + .then(response => response.json()) + .then(result => { + const listContainer = document.getElementById("list-bundles"); + listContainer.innerHTML = "<p>Bundles referring to this vulnerability.</p>"; + + if (result.metadata.count === 0) { + listContainer.innerHTML = "<p>This vulnerability is not linked to any bundle.</p>"; + return; + } + // Sort newest first + result.data.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at)); + + result.data.forEach(bundle => { + const author = bundle.author; + delete bundle.author; + + // Safely render description through showdown + post-processing + let description = converter.makeHtml(bundle.description || ""); + description = linkifySecurityIdentifiers(description); + + // Card container + const card = document.createElement("div"); + card.className = "card markdown-description mb-3"; + + const body = document.createElement("div"); + body.className = "card-body"; + + // Title + const title = document.createElement("h5"); + title.className = "card-title"; + const titleLink = document.createElement("a"); + titleLink.href = "/bundle/" + encodeURIComponent(bundle.uuid); + titleLink.textContent = bundle.name; + title.appendChild(titleLink); + + // Subtitle + const subtitle = document.createElement("h6"); + subtitle.className = "card-subtitle mb-2 text-body-secondary"; + subtitle.innerHTML = `${DateTime.fromISO(bundle.timestamp).toRelative()} by + <a href="/user/${encodeURIComponent(author.login)}">${author.name}</a>`; + + // Description (trusted markdown conversion + sanitization) + const desc = document.createElement("p"); + desc.className = "card-text"; + desc.innerHTML = description; + + // Related vulnerabilities + const vulnTitle = document.createElement("h5"); + vulnTitle.className = "card-text"; + vulnTitle.textContent = "Related vulnerabilities"; + + const vulnCard = document.createElement("div"); + vulnCard.className = "card"; + + const vulnList = document.createElement("ul"); + vulnList.className = "list-group list-group-flush"; + + (bundle.related_vulnerabilities || []).forEach(v => { + const li = document.createElement("li"); + li.className = "list-group-item"; + const vulnLink = document.createElement("a"); + vulnLink.href = "/vuln/" + encodeURIComponent(v.toLowerCase()); + vulnLink.textContent = v.toLowerCase(); + li.appendChild(vulnLink); + vulnList.appendChild(li); + }); + + vulnCard.appendChild(vulnList); + + // Assemble card + body.appendChild(title); + body.appendChild(subtitle); + body.appendChild(desc); + body.appendChild(vulnTitle); + body.appendChild(vulnCard); + card.appendChild(body); + + listContainer.appendChild(card); + }); + }) + .then(() => { + // Defer markdown formatting until DOM is updated + setTimeout(() => { + formatMarkdownOutput(); + }, 0); + }) + .catch(error => { + console.error("Error loading bundles:", error); + }); + } function loadSightings() { fetch("{{ url_for('apiv1.sighting_sightings_list', vuln_id=vulnerability_id) }}&date_from=1970-01-01") - .then(response => response.json()) - .then(result => { - document.getElementById("nb-sightings").innerText = result.metadata.count; - if (result.metadata.count == 0) { - document.getElementById("sightings-pane-top").style.display = 'none'; - document.getElementById("chart-sightings").innerHTML = "<p>No sightings for this vulnerability.</p>"; - document.getElementById("sightingsChartContainer").style.display = 'none'; - document.getElementById("chart-detailed-legend").style.display = 'none'; - } else{ - drawBarChart(result.data); - // document.getElementById("sightings-pane-top").style.display = 'block'; - document.getElementById("chart-sightings").innerHTML = "<h3>Evolution of sightings over time</h3>"; - document.getElementById("sightingsChartContainer").style.display = 'block'; - document.getElementById("chart-detailed-legend").style.display = 'block'; - - // clear the table - const tableBody = document.getElementById("sighting-table-body"); - while (tableBody.firstChild) { - tableBody.removeChild(tableBody.firstChild); - } + .then(response => response.json()) + .then(result => { + const nbSightings = document.getElementById("nb-sightings"); + nbSightings.textContent = result.metadata.count; + + const paneTop = document.getElementById("sightings-pane-top"); + const chartSightings = document.getElementById("chart-sightings"); + const chartContainer = document.getElementById("sightingsChartContainer"); + const chartLegend = document.getElementById("chart-detailed-legend"); + const tableBody = document.getElementById("sighting-table-body"); + + if (result.metadata.count === 0) { + paneTop.style.display = "none"; + chartSightings.textContent = "No sightings for this vulnerability."; + chartContainer.style.display = "none"; + chartLegend.style.display = "none"; + return; + } - result.data - .sort(function (a, b) { - return new Date(b.creation_timestamp) - new Date(a.creation_timestamp); - }) - .map(function (sighting) { - const row = document.createElement('tr'); // Create a table row - - // Create and append the Author cell - const authorCell = document.createElement('td'); - // authorCell.textContent = sighting.author.login; - authorCell.innerHTML = '<a href="/user/'+sighting.author.login+'">'+sighting.author.login+'</a>'; - row.appendChild(authorCell); - - // Create and append the Source cell - const sourceCell = document.createElement('td'); - if (isValidURL(sighting.source)) { - sourceCell.innerHTML = '<a href="'+sighting.source+'" rel="noreferrer" target="_blank">'+sighting.source+'</a> (<a href="/sighting/'+sighting.uuid+'/correlations">correlations</a>)'; - } else { - sourceCell.innerHTML = sighting.source+' (<a href="/sighting/'+sighting.uuid+'/correlations">correlations</a>)'; - } - row.appendChild(sourceCell); + drawBarChart(result.data); - // Create and append the Type cell - const typeCell = document.createElement('td'); - typeCell.textContent = sighting.type; - row.appendChild(typeCell); + chartSightings.innerHTML = "<h3>Evolution of sightings over time</h3>"; + chartContainer.style.display = "block"; + chartLegend.style.display = "block"; - // Create and append the Date cell - const dateCell = document.createElement('td'); - dateCell.classList.add('datetime'); - dateCell.textContent = sighting.creation_timestamp; - dateCell.title = sighting.creation_timestamp; - row.appendChild(dateCell); + // Clear the table + while (tableBody.firstChild) { + tableBody.removeChild(tableBody.firstChild); + } - document.getElementById("sighting-table-body").appendChild(row); + // Sort newest first + result.data.sort((a, b) => new Date(b.creation_timestamp) - new Date(a.creation_timestamp)); + + result.data.forEach(sighting => { + const row = document.createElement("tr"); + + // Author cell + const authorCell = document.createElement("td"); + const authorLink = document.createElement("a"); + authorLink.href = "/user/" + encodeURIComponent(sighting.author.login); + authorLink.textContent = sighting.author.login; + authorCell.appendChild(authorLink); + row.appendChild(authorCell); + + // Source cell + const sourceCell = document.createElement("td"); + if (isValidURL(sighting.source)) { + const sourceLink = document.createElement("a"); + sourceLink.href = sighting.source; + sourceLink.target = "_blank"; + sourceLink.rel = "noreferrer"; + sourceLink.textContent = sighting.source; + sourceCell.appendChild(sourceLink); + } else { + sourceCell.textContent = sighting.source || ""; + } + + // Add correlations link safely + sourceCell.appendChild(document.createTextNode(" (")); + const corrLink = document.createElement("a"); + corrLink.href = "/sighting/" + encodeURIComponent(sighting.uuid) + "/correlations"; + corrLink.textContent = "correlations"; + sourceCell.appendChild(corrLink); + sourceCell.appendChild(document.createTextNode(")")); + + row.appendChild(sourceCell); + + // Type cell + const typeCell = document.createElement("td"); + typeCell.textContent = sighting.type; + row.appendChild(typeCell); + + // Date cell + const dateCell = document.createElement("td"); + dateCell.classList.add("datetime"); + dateCell.textContent = sighting.creation_timestamp; + dateCell.title = sighting.creation_timestamp; + row.appendChild(dateCell); + + tableBody.appendChild(row); + }); + + // Convert timestamps to relative time + const DateTime = luxon.DateTime; + document.querySelectorAll(".datetime").forEach(element => { + element.textContent = DateTime.fromISO(element.textContent).toRelative(); + }); }) - - var DateTime = luxon.DateTime; - elements = document.getElementsByClassName("datetime"); - Array.prototype.forEach.call(elements, function(element) { - element.textContent = DateTime.fromISO(element.textContent).toRelative() + .catch(error => { + console.error("Error loading sightings:", error); }); - - } - }) - .catch((error) => { - console.error('Error:', error); - }); - }; + } function parseMitigationDescription(raw) {
website/web/views/bundle.py+1 −1 modified@@ -17,7 +17,7 @@ from markdown.extensions.tables import TableExtension # type: ignore[import-untyped] from vulnerabilitylookup.default import get_config -from website.lib.utils import sanitize_html_fragment +from website.lib.sanitizers import sanitize_html_fragment from website.models import Bundle from website.web.bootstrap import application
website/web/views/comment.py+1 −1 modified@@ -24,7 +24,7 @@ from werkzeug import Response as WerkzeugResponse from vulnerabilitylookup.default import get_config -from website.lib.utils import sanitize_html_fragment +from website.lib.sanitizers import sanitize_html_fragment from website.models import Comment from website.web.bootstrap import application, db
website/web/views/user.py+3 −2 modified@@ -34,8 +34,10 @@ from werkzeug.security import generate_password_hash from vulnerabilitylookup.default import get_config +from website.decorators import auth_feedkey_func +from website.lib.sanitizers import sanitize_html_fragment from website.lib.user_utils import confirm_token -from website.lib.utils import sanitize_html_fragment, top_contributors +from website.lib.utils import top_contributors from website.models import ( Bundle, Comment, @@ -47,7 +49,6 @@ VulnerabilityDisclosure, ) from website.notifications import notifications -from website.decorators import auth_feedkey_func from website.web.bootstrap import application, db, vulnerabilitylookup from website.web.forms import ( AccountConfirmationForm,
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
1News mentions
0No linked articles in our index yet.