Feb 26, 2026·7 min read·5 visits
Vikunja < 2.0.0 improperly handles the `filter` URL parameter when initializing the TipTap text editor. By passing a raw string to the editor's `setContent` method, the application inadvertently triggers HTML parsing. This allows attackers to inject specific HTML tags (like `<a>`, `<svg>`, `<h1>`), bypassing some sanitization, to conduct high-confidence phishing attacks or content spoofing.
A Reflected HTML Injection vulnerability in the Vikunja task management platform allows attackers to inject arbitrary HTML tags into the application's DOM via the filter parameter. By exploiting the way the TipTap editor parses string content, attackers can craft convincing phishing scenarios or redirect users to malicious sites, all under the guise of a trusted application URL.
Vikunja is the darling of the self-hosted productivity world. It’s slick, it’s written in Go, and it promises to organize your life without selling your soul to a SaaS giant. But in versions prior to 2.0.0, it also offered a feature nobody asked for: a built-in platform for hosting high-fidelity phishing campaigns directly inside your own dashboard.
The vulnerability lies in a classic disconnect between modern frontend frameworks and the underlying libraries they abstract. We often assume that using a framework like Vue.js protects us from the sins of the past—like Reflected XSS or HTML Injection. Usually, it does. But when you start integrating rich text editors, you enter the Wild West of DOM manipulation. In this case, the Projects module allowed users to filter tasks via a URL parameter. Convenient? Yes. Safe? Absolutely not.
What makes CVE-2026-27116 interesting isn't that it's a complex buffer overflow or a heap grooming masterpiece. It's a logic error in how data is handed off to TipTap, a wrapper around the ProseMirror editor. The developer treated user input like a simple string, but the library treated it like a browser innerHTML assignment. This mismatch turns a simple 'search filter' into a canvas for attackers to paint whatever reality they want your users to see.
To understand this bug, you have to understand how TipTap handles content. It’s a powerful editor that tries to be helpful. Too helpful. The core method involved here is editor.commands.setContent(). This function is a bit of a chameleon. If you pass it a structured JSON object (a ProseMirror document node), it renders exactly what you tell it to. It treats text as text, bold as bold, and so on. It is type-strict and safe.
However, if you pass setContent() a raw string, TipTap assumes you are lazy and tries to do the heavy lifting for you. It invokes a DOMParser to parse that string as HTML. It’s the library equivalent of saying, "Oh, you gave me a blob of text? Let me just run this through the browser's HTML engine and see what happens." This is the root cause: Implicit Type Coercion leads to Implicit HTML Parsing.
In frontend/src/components/input/filter/FilterInput.vue, the code was taking the filter query parameter directly from the URL—unverified, unloved, and unclean—and shoving it straight into setContent(). The developer likely intended for the filter to just populate the text box so the user could see what they were searching for. instead, they built a mechanism that takes ?filter=<h1>Surprise</h1> and actually renders a header tag in the UI. It's like trying to display a license plate number but accidentally letting the driver rewrite the traffic laws.
Let's look at the smoking gun. This is the code inside FilterInput.vue responsible for initializing the editor with the filter value. In the vulnerable version, the input is treated as a raw string.
Vulnerable Code:
function setEditorContentFromModelValue(newValue: string | undefined) {
// ... checks ...
// 'content' here comes directly from the URL query parameter
editor.value.commands.setContent(content, {
emitUpdate: false,
})
}When content is a string containing HTML tags, TipTap parses it. The fix involves forcing TipTap to treat the input as a specific node type—specifically, a paragraph of text. By constructing a ProseMirror JSON object manually, the developer removes ambiguity. The editor no longer has to guess if the input is HTML; it is explicitly told, "This is a text node. Render it literally."
The Fix (Commit a42b4f37bde58596a3b69482cd5a67641a94f62d):
// Use JSON content format instead of a plain string to prevent
// TipTap from parsing the value as HTML
editor.value.commands.setContent(content
? {
type: 'doc',
content: [{
type: 'paragraph',
content: [{type: 'text', text: content}],
}],
}
: '', {emitUpdate: false})Notice the structure. We are manually building the Document Object Model (DOM) tree structure in JSON. { type: 'text', text: content }. Now, if content is <b>bold</b>, the editor renders the literal characters < b > rather than making the text bold.
So, we have HTML injection. "But wait," you say, "TipTap sanitizes input! You can't just inject <script> tags!" You are correct. If you try to inject <script>alert(1)</script>, TipTap's sanitizer (usually based on the Schema) will likely strip it out. We aren't getting a low-effort XSS here. But we don't need JavaScript to ruin someone's day.
We have HTML Injection. We can inject <a> tags, header tags (<h1>), and dangerously, <svg> tags (depending on the schema configuration). This allows for Content Spoofing. An attacker can craft a link to a legitimate Vikunja instance that, when clicked, renders a fake UI overlay.
The Attack Scenario:
https://tasks.corp.com/projects/1?filter=<h3>Session+Expired</h3><a+href='https://evil.com/login'>Click+here+to+re-authenticate</a>.Session Expired in the input box, they see a rendered Header and a clickable link that looks native to the application styling.Because the link is hosted on the trusted domain (tasks.corp.com), the victim's guard is down. This is effectively a legitimate-looking defacement that only exists for that specific user in that specific moment. It’s perfect for credential harvesting.
CVSS gives this a 6.1. It calls it 'Medium'. In the sterile world of vulnerability scoring, that makes sense. It requires user interaction (UI:R), and the scope change (S:C) is limited because we aren't popping full shells or stealing cookies directly via JS (yet). But in the real world, this is a high-risk vector for initial access.
Consider the context: Vikunja is a productivity tool. It contains sensitive project data, deadlines, and potentially API keys or secrets in task descriptions. If an attacker can successfully phish a user via this method, they gain valid credentials. From there, they can pivot to data exfiltration or internal sabotage.
Furthermore, while <script> is blocked by default, sanitizers are notoriously difficult to get right. If there is any allowed tag attribute that accepts a javascript: pseudo-protocol (like a lax href on an anchor tag) or an event handler that slips through the ProseMirror schema, this elevates instantly from HTML Injection to full Reflected XSS. Even without JS, the ability to fundamentally alter the visual trust of the application page is powerful. It breaks the 'What You See Is What You Get' contract of the web.
The remediation here is straightforward, as seen in the patch analysis. If you are running Vikunja self-hosted, stop what you are doing and pull the 2.0.0 container image. The update fundamentally changes the data flow for the filter component.
For developers reading this, the lesson is broader than just Vikunja or TipTap. Never rely on implicit parsing of user input. If an API accepts a string or an object, and the string path triggers a parser (Markdown, HTML, XML), you are walking into a minefield. Always choose the structured, typed path.
If you cannot update for some reason (maybe you really love living on the edge), you could attempt to block the attack at the WAF level by filtering query parameters containing HTML tags (<, >), but that's a game of whack-a-mole you will eventually lose. The only true fix is the code change: force the editor to treat the input as a text node, rendering the HTML tags as harmless strings rather than executable DOM elements.
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N| Product | Affected Versions | Fixed Version |
|---|---|---|
Vikunja Vikunja | < 2.0.0 | 2.0.0 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-79 |
| Attack Vector | Network |
| CVSS Score | 6.1 (Medium) |
| Impact | Content Spoofing / Phishing |
| Vulnerability | Reflected HTML Injection |
| Component | FilterInput.vue / TipTap Editor |
Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')