VYPR
Medium severity6.4OSV Advisory· Published Sep 25, 2025· Updated Apr 15, 2026

CVE-2025-60249

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

Patches

1
afa12347f146

fix: [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

1

News mentions

0

No linked articles in our index yet.