| Description | | Item | Details |
|---|---|
| Affected software | FFXIV Teamcraft (web and desktop apps share the front-end code; the desktop app is an Electron application) |
| Affected components | ① DB page comment feature (`apps/client/src/app/pages/db/db-comments/`); ② renderer-process IPC bridge (`apps/electron/src/preload.ts`) and main-process IPC listeners (`apps/electron/src/ipc/ipc-listeners-manager.ts`) |
| Affected version | FFXIV Teamcraft v11.4.31 (latest release at the time of reporting) |
| Attack prerequisite | The attacker is a logged-in user (submits the malicious comment); the victims are any users who view the same DB page (no other interaction required) |
| Severity | High |
| Vulnerability type | Stored cross-site scripting CWE-79 + sensitive information exposure CWE-200 / improper privilege control CWE-284 (chained) |
---
## 1. Vulnerability Description
This vulnerability is a complete attack chain built from two reliably connected defects:
**Stage 1: persistent stored XSS in database comments.** The entire path from submission, storage, to display of comment content has no HTML escaping or sanitization: the submit side writes the textarea content into the comment record as-is; the display side renders it into an Angular `[innerHTML]` binding; and the link-handling pipe in between concatenates the raw string and then calls `bypassSecurityTrustHtml` to trust the whole result (that call by definition "skips Angular's built-in sanitization", and the pipe contains no escaping or sanitization call of any kind). Any logged-in user can persist arbitrary HTML/scripts in a comment, and every user who subsequently views that page will have the content executed in their browser (web) or renderer (desktop app).
**Stage 2: the renderer-process IPC bridge exposes sensitive capabilities.** The desktop app exposes a channel-allowlist IPC bridge to the renderer via `contextBridge.exposeInMainWorld('ipc', ...)`; the allowlist only checks "is the channel name allowed" and does not distinguish the caller's context — an attacker who gained script execution through stage 1 can directly invoke main-process capabilities:
1. **Read the system clipboard** (`clipboard:get` → main-process `clipboard.readText()`, system-wide) — can be polled periodically to steal passwords, 2FA codes, and tokens that the user copies in daily use;
2. **Open arbitrary external links** (`open-link`, validates only the protocol, not the domain) — a phishing springboard;
3. **Forge system notifications** (`notification`, with the official Teamcraft icon) — e.g., a fake "new version available" prompt to induce downloads;
4. **Persistently hijack the proxy/PAC settings** (`proxy-rule`/`proxy-pac`, written to the local config and **still effective after restart**) — all subsequent application requests (Firebase, ffxivteamcraft.com, including logged-in sessions) can be routed to the attacker's proxy;
5. **Open arbitrary routes in always-on-top overlay / child windows** (`overlay`/`child:new`) — UI spoofing inside the trusted app;
6. **Read local data**: application state (lists/layouts), the local inventory file, and the item-order data in the FFXIV game save directory (including every character ContentID).
**Combined impact**: any desktop user who simply views a malicious comment can have their clipboard silently stolen (passwords/2FA), their application traffic persistently hijacked, and be phished via official-icon notifications — all inside the trusted application, with no user interaction beyond opening the page. On the web, the impact is that of a classic stored XSS (session abuse / in-app operations / injected phishing content).
---
## 2. Root Cause Analysis (Key Source Code)
### 2.1 Stage 1 · Render side: the `[innerHTML]` binding
`apps/client/src/app/pages/db/db-comments/db-comments/db-comments.component.html` (lines 48-49):
```html
<p class="comment-content"
[innerHTML]="comment.message | commentLinks:translate.currentLang:comment | async"></p>
```
The comment content is piped through `commentLinks` and bound to `[innerHTML]` — whether this is safe depends entirely on whether the pipe escapes.
### 2.2 Stage 1 · Pipe side: concatenation, then blanket trust, with no escaping
`apps/client/src/app/pages/db/db-comments/comment-links.pipe.ts` (lines 116-120, excerpt):
```ts
// ... regex replacement over the raw string, concatenating anchor HTML ...
return this.sanitizer.bypassSecurityTrustHtml(value);
```
The pipe performs plain string concatenation over the user-controlled raw string and then calls `bypassSecurityTrustHtml` to trust the entire result. The file contains no `escapeHtml`, `DOMPurify`, or `sanitizer.sanitize` — no escaping or sanitization call of any kind.
### 2.3 Stage 1 · Submit side: arbitrary content stored as-is
`apps/client/src/app/pages/db/db-comments/db-comments/db-comments.component.ts` (lines 186-199, excerpt):
```ts
const comment = ...;
comment.message = this.newCommentContent; // ← textarea raw text, unfiltered
this.newCommentContent = '';
// followed by commentsService.add(comment) writing it to the database
```
The submission template (`db-comments.component.html:133-145`) is a plain textarea that accepts arbitrary text.
### 2.4 Stage 2 · The IPC bridge is open to any renderer script
`apps/electron/src/preload.ts` (lines 158-180):
```ts
contextBridge.exposeInMainWorld('ipc', {
init: () => { ipcRenderer.setMaxListeners(0); },
removeAllListeners: (channel: string) => { ipcRenderer.removeAllListeners(channel); },
send: (channel: string, ...data: any[]) => {
if (checkChannel(channel)) { ipcRenderer.send(channel, ...data); }
},
on: (channel: string, listener: (event: IpcRendererEvent, ...args: any[]) => void) => {
if (checkChannel(channel)) { ipcRenderer.on(channel, listener); }
},
...
});
```
The allowlist (`preload.ts:5-137`) includes dozens of channels such as `clipboard`, `clipboard:get`, `open-link`, `notification`, `proxy-rule`, `proxy-pac`, `overlay`, `child:new`, `app-state:get`, `inventory:get`, and `dat:all-odr`; `checkChannel` only performs a name match and never checks the caller's origin or context.
### 2.5 Stage 2 · System clipboard read (no origin / gesture check)
`apps/electron/src/ipc/ipc-listeners-manager.ts` (lines 168-170):
```ts
ipcMain.on('clipboard:get', (event) => {
event.sender.send('clipboard', clipboard.readText()); // ← system-wide clipboard, callable by any script
});
```
The legitimate business use is "recipe finder import from clipboard" (`apps/client/src/app/pages/recipe-finder/recipe-finder/recipe-finder.component.ts:296-305`), but the channel is open to any renderer-process script, with no user-gesture restriction.
### 2.6 Stage 2 · Opening arbitrary links and forging system notifications
`ipc-listeners-manager.ts` (lines 390-415):
```ts
ipcMain.on('open-link', (event, url) => {
if (!['https:', 'http:'].includes(new URL(url).protocol)) return; // ← validates only the protocol, not the domain
shell.openExternal(url);
});
ipcMain.on('notification', (event, config) => {
const iconPath = join(Constants.BASE_APP_PATH, 'assets/app-icon.png');
if (process.platform === 'win32' && this.trayMenu.tray) {
config.icon = nativeImage.createFromPath(iconPath); // ← with the official icon
config.silent = true;
this.trayMenu.tray.displayBalloon(config); // ← Windows tray balloon, content fully caller-controlled
} else { ... new Notification({...}).show(); }
});
```
|
|---|