Who this is for: n8n developers who write custom JavaScript nodes and need to protect workflows that expose data to browsers (webhooks, UI pages, email templates, etc.). We cover this in detail in the n8n Security & Hardening Guide.
Quick diagnosis
Custom JavaScript nodes that embed user‑supplied data into HTML, JSON, or HTTP responses are prime XSS injection points. The fastest remediation is to sanitize every external value before it reaches a browser context (e.g., innerHTML, res.send(), return). Use a proven library such as DOMPurify or n8n’s built‑in sanitize helper, then re‑test the node with the n8n test runner.
1. Understanding XSS in n8n custom code
If you encounter any database injection risks resolve them before continuing with the setup.
Browser contexts & typical entry points
| Browser context | Typical n8n entry point |
|---|---|
| HTML | return value rendered in a UI webview or email template |
| JavaScript | eval‑style string concatenation in a downstream node |
| URL | Query string built for an HTTP request |
| JSONP | Callback wrapper returned to a client |
Vulnerable patterns
| Example vulnerable pattern |
|---|
| return `
${item.json.userInput}
`; |
| this.helpers.executeWorkflow(`alert(‘${item.json.payload}’)`); |
| requestOptions.url = `https://example.com/search?q=${item.json.term}`; |
| return `${item.json.callback}(${JSON.stringify(data)})`; |
EEFA note: In production, n8n runs under the
n8nuser inside a Docker container. Even with container isolation, XSS can still compromise any client that consumes the workflow’s output (e.g., a public dashboard). Treat XSS as a client‑side breach with server‑side impact.
2. Common XSS injection vectors
| Vector | Source of data |
|---|---|
| User‑provided query parameters | this.getNodeParameter('query') from a webhook |
| Headers from upstream APIs | item.json.headers['User-Agent'] |
| File contents uploaded via HTTP node | item.binary.data decoded to string |
| Environment variables | process.env.CUSTOM_MESSAGE |
| Dynamic workflow names | this.getWorkflowStaticData('node') |
How it reaches the browser – each source can be interpolated into HTML, JavaScript, URLs, or JSONP that later renders in a client’s browser.
EEFA note: Environment variables are often considered “safe” because they’re set by the operator, but if they are populated from external sources (e.g., CI pipelines), they become attack vectors. Validate them the same way as user input. If you encounter any privilege escalation workflow execution resolve them before continuing with the setup.
3. Step‑by‑step audit: find XSS in a custom JavaScript node
Micro‑summary: Identify every external value, map it to a browser context, and replace direct interpolation with a sanitizer.
- Locate external data accesses – search for
this.getNodeParameter,item.json,item.binary,process.env, and any awaited HTTP responses. - Map each value to a browser context – does it end up in HTML, JavaScript, URL, or JSONP?
- Flag direct interpolation – look for template literals or string concatenation that inject the value without escaping.
Vulnerable snippet (before)
// Direct interpolation – unsafe return;
Fixed snippet (after) – using n8n’s built‑in helper
// Escape HTML before insertion const safe = this.helpers.escapeHTML(item.json.userInput); return;
4. Add a unit test that sends a malicious payload and asserts the output is clean.
Test skeleton
import { runWorkflow } from 'n8n-core';
test('XSS mitigation', async () => {
const result = await runWorkflow('my-xss-node', {
userInput: '
',
});
expect(result).not.toContain('onerror');
});
EEFA note: The test runner executes workflows in an isolated Docker container, but the test code itself runs on the host. Ensure the host environment does not expose internal state via
NODE_OPTIONS=--inspectin CI.
4. Sanitization strategies
Micro‑summary: Choose the lightest sanitizer that satisfies the data’s complexity; prefer built‑in helpers for simple text and DOMPurify for rich HTML.
4.1 Built‑in HTML escape
// Simple text escaping const safe = this.helpers.escapeHTML(value);
4.2 DOMPurify (server‑side)
// Install once: npm install dompurify jsdom
const { JSDOM } = require('jsdom');
const createDOMPurify = require('dompurify');
// Create a reusable purifier
function getPurifier() {
const window = new JSDOM('').window;
return createDOMPurify(window);
}
// Sanitize rich HTML fragments
function sanitizeHTML(input) {
const DOMPurify = getPurifier();
return DOMPurify.sanitize(input, {
ALLOWED_TAGS: ['b', 'i', 'strong', 'em', 'a'],
ALLOWED_ATTR: ['href', 'title'],
});
}
// Example usage in a node const raw = item.json.richText; // user supplied const safeHtml = sanitizeHTML(raw); return safeHtml;
4.3 validator.js for URL‑safe values
const { escape } = require('validator');
const safe = escape(userInput);
4.4 Custom whitelist (high‑performance)
const allowed = /^[a-zA-Z0-9 _-]+$/;
if (!allowed.test(value)) {
throw new Error('Invalid characters');
}
4.5 Content‑Security‑Policy (defense‑in‑depth)
res.setHeader( 'Content-Security-Policy', "default-src 'self'; script-src 'self'" );
Recommended default for n8n custom nodes – use the DOMPurify helper for any HTML fragment; fall back to escapeHTML for plain text.
EEFA note: When using
jsdominside n8n, ensure the container has at least 256 MiB of RAM; DOMPurify can be memory‑intensive for large payloads. Cache thewindowinstance outside the node’sexecutemethod for high‑throughput workflows.
5. Production‑grade XSS hardening checklist
Micro‑summary: Verify sanitization, CSP, dependency hygiene, static analysis, monitoring, and container hardening.
| Item | Description |
|---|---|
| All external values are sanitized | Every this.getNodeParameter, item.json, item.binary, process.env value passes through a sanitizer before reaching a browser context. |
| CSP header set on webhook responses | Prevents inline script execution even if a payload slips through. |
| Node dependencies are locked | package-lock.json pinned; no vulnerable versions of DOMPurify or validator. |
| Static analysis enabled | Custom ESLint rule no-unsanitized flags direct interpolation. |
| Runtime monitoring | Log any sanitization failures with request ID for forensic review. |
| Least‑privilege container | Node runs as non‑root user; filesystem write access limited to /data. |
Verification steps – run the unit test suite with known XSS payloads, inspect response headers with curl -I, run npm audit, ensure CI fails on ESLint warnings, and review logs for SanitizationError entries.
6. Automated testing & continuous integration
Micro‑summary: Add dedicated XSS tests, inject OWASP cheat‑sheet payloads, and enforce coverage thresholds.
- Create a test file (
tests/xss.test.js). - Inject payloads from the OWASP XSS Filter Evasion Cheat Sheet.
- Assert the output is clean and that a sanitization log entry appears.
Payload array
const payloads = [ '<svg/onload=alert(1)>' ];
Test suite
import { executeWorkflow } from 'n8n-core';
describe('XSS protection in custom JS node', () => {
payloads.forEach(p => {
test(`sanitizes ${p}`, async () => {
const result = await executeWorkflow('my-custom-node', {
userInput: p,
});
expect(result).not.toMatch(/<script|onerror|onload/);
});
});
});
CI tip: Fail the pipeline if coverage for sanitizeHTML drops below 95 % or if any ESLint no-unsanitized warnings appear.
Final Fix
const safe = this.helpers.escapeHTML(item.json.userInput); // or DOMPurify for rich HTML return `
`;
Replace every direct interpolation in custom JavaScript nodes with a sanitizer, run the XSS unit tests, and enforce the checklist in CI to eliminate XSS vectors from your n8n workflows.



