# Auditing Outline. Firsthand lessons from comparing manual testing and AI security platforms

Koa's response.attachment() silently overrides Content-Type, letting Outline serve attacker-uploaded HTML/JS inline; chained with sibling-domain CSRF to take over admin accounts.

- Payout: Research
- Severity: medium
- Program: Outline
- Write-up by: Doyensec
- Disclosed: 2026-02-02
- Write-up quality: 8/10
- Original: https://blog.doyensec.com/2026/02/03/outline-audit-q32025.html
- Library page: https://bugbountylibrary.com/r/blog%3Ablog-doyensec-com-2026-02-03-outline-audit-q32025-html
- Tags: area:web domain:web impact:ato kind:research platform:research sev:medium tech:nodejs technique:chained technique:source-review vuln:csrf vuln:misconfiguration vuln:xss

**Target:** Koa-based Node.js wiki server file storage and attachment endpoint
**Root cause:** Koa's response.attachment() setter re-derives and overwrites Content-Type via getType(), discarding the safe allow-list Content-Type set beforehand; combined with Content-Disposition: attachment not blocking script execution for valid JS MIME types, uploaded files render as active content.
**Steps:**
1. Upload a file whose content type is application/javascript (or HTML) to Outline local file storage
2. Request the attachment; server sets `ctx.set("Content-Type", contentType)` then `ctx.attachment(fileName, {type: forceDownload ? "attachment" : FileStorage.getContentDisposition(contentType)})`
3. Koa's attachment setter calls `getType(type)` and sets Content-Type, overriding the allow-list decision
4. Browser executes the script despite `Content-Disposition: attachment` because MIME is application/javascript
5. Chain with CSRF via sibling domains (OUT-Q325-03) and Outline sharing to hijack an admin session
**How found:** Investigating OUT-Q325-04 (CSP bypass via local file storage) led researchers to examine BaseStorage.getContentDisposition allow-list; noticing the response Content-Type was overridden despite the logic, they traced it to Koa's undocumented attachment() setter behavior.
**Exploitation:** Attacker uploads a malicious JS/HTML file, gets a victim (admin) to load it from the Outline origin; script executes in-origin, then combined with sibling-domain CSRF and sharing features to take over the admin account.
**Escalation:** Chained OUT-Q325-06 (ContentType bypass XSS) with OUT-Q325-03 (CSRF via sibling domains) and Outline sharing to escalate from stored XSS to full admin account takeover.
**Insight:** When a framework helper like Koa's attachment() sets headers, check whether it re-derives values (getType) and clobbers earlier explicit sets; also note Content-Disposition: attachment does not stop execution of valid JS MIME types in Chrome/Firefox.

## Proof of concept (copied exactly from the original report)

**events.list IDOR handler code**

```
router.post(
 "events.list",
 auth(),
 pagination(),
 validate(T.EventsListSchema),
 async (ctx: APIContext<T.EventsListReq>) => {
 const { user } = ctx.state.auth;
 const {
 name,
 events,
 auditLog,
 actorId,
 documentId,
 collectionId,
 sort,
 direction,
 } = ctx.input.body;

 let where: WhereOptions<Event> = {
 teamId: user.teamId,
 };

 if (auditLog) {
 authorize(user, "audit", user.team);
 where.name = events
 ? intersection(EventHelper.AUDIT_EVENTS, events)
 : EventHelper.AUDIT_EVENTS;
 } else {
 where.name = events
 ? intersection(EventHelper.ACTIVITY_EVENTS, events)
 : EventHelper.ACTIVITY_EVENTS;
 }

 if (name && (where.name as string[]).includes(name)) {
 where.name = name;
 }

 if (actorId) {
 where = { ...where, actorId };
 }

 if (documentId) {
 where = { ...where, documentId };
 }

 if (collectionId) {
 where = { ...where, collectionId };

 const collection = await Collection.findByPk(collectionId, {
 userId: user.id,
 });
 authorize(user, "read", collection);
 } else {
 const collectionIds = await user.collectionIds({
 paranoid: false,
 });
 where = {
 ...where,
 [Op.or]: [
 {
 collectionId: collectionIds,
 },
 {
 collectionId: {
 [Op.is]: null,
 },
 },
 ],
 };
 }

 const loadedEvents = await Event.findAll({
 where,
 order: [[sort, direction]],
 include: [
 {
 model: User,
 as: "actor",
 paranoid: false,
 },
 ],
 offset: ctx.state.pagination.offset,
 limit: ctx.state.pagination.limit,
 });

 ctx.body = {
 pagination: ctx.state.pagination,
 data: await Promise.all(
 loadedEvents.map((event) => presentEvent(event, auditLog))
 ),
 };
 }
);
```

**Koa attachment Content-Type override**

```
ctx.set("Content-Type", contentType);
ctx.attachment(fileName, {
 type: forceDownload
 ? "attachment"
 : FileStorage.getContentDisposition(contentType), // this applies the safe allowed-list
 });


In fact, the attachment function performs an unexpected:

 set type (type) {
 type = getType(type)
 if (type) {
 this.set('Content-Type', type)
 } else {
 this.remove('Content-Type')
 }
},
```

**getContentDisposition safe inline allow-list**

```
public getContentDisposition(contentType?: string) {
 if (!contentType) {
 return "attachment";
 }
```

