Skip to content

Commit bb0c5e4

Browse files
authored
Security Hardening: XSS Protection (v1.1.0) (#10)
## Overview Adds built-in XSS protection with attribute sanitization, URL scheme validation, and same-origin enforcement for template includes. ## What's New - 🔒 **Default XSS Protection**: Blocks dangerous attributes (`on*`, `srcdoc`), validates URL schemes, enforces same-origin - 📦 **Dual Bundle**: `faintly.js` (2736b) + `faintly.security.js` (646b) = 3382 bytes gzipped (under 6KB limit) - 🔧 **Configurable**: Disable with `security: false`, customize with config, or provide custom hooks - 🛡️ **Template Security**: Initial template fetch now protected by same-origin check - 🚀 **Performance**: Security initialized once per render, no overhead in directives ## Security Features ### Attribute Sanitization - Blocks event handlers: `/^on/i` pattern (onclick, onerror, etc.) - Blocks dangerous attributes: `srcdoc` - Validates URL schemes in `href`, `src`, `action`, `formaction`, `xlink:href` - Allows: `http:`, `https:`, `mailto:`, `tel:` (relative URLs always allowed) ### Template Include Protection - Enforces same-origin for all template includes - Protects initial template fetch in `resolveTemplate()` - Blocks cross-origin URLs early with clear warnings ## Documentation - ✅ Comprehensive README security section with examples - ✅ AGENTS.md updated with security module guidelines - ✅ Strong warnings about user-supplied data in context ## Testing - ✅ **94 tests passed, 0 skipped** - ✅ **100% code coverage maintained** - ✅ All existing tests pass (backward compatible) - ✅ TDD approach for all security features ## Architecture - Security initialization in `renderElement()` (single point, happens once) - `initializeSecurity()` exported for tests calling directives directly - Directives use `context.security` directly (clean separation) - Security module dynamically loaded on first use (tree-shakeable) ## Backward Compatibility ✅ **Fully backward compatible** - existing code works unchanged - Security enabled by default (transparent to users) - `security: false` or `security: 'unsafe'` to disable - Custom security hooks supported ## Breaking Changes None. This is a minor version bump (`1.0.1` → `1.1.0`). ## Bundle Size - Core: 2736 bytes gzipped (under 4KB limit) - Security: 646 bytes gzipped - **Total: 3382 bytes gzipped** (well under 6KB limit) ## Commits 1. Phase 1: Clean slate with directives.js security integration 2. Implement shouldAllowAttribute with TDD approach 3. Remove inline comments from DEFAULT_CONFIG 4. Unskip default security test for processAttributes 5. Refactor to allowedTemplatePaths and unskip all tests 6. Simplify allowIncludePath to same-origin only 7. Add comprehensive security documentation to README 8. Update AGENTS.md with security module documentation 9. Refactor security initialization to renderElement 10. Bump version to 1.1.0 for security feature release
1 parent d7fbbf5 commit bb0c5e4

14 files changed

Lines changed: 834 additions & 42 deletions

AGENTS.md

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Authoritative guide for AI/code agents contributing to this repository.
55
### Project purpose
66
- **What this is**: A small, dependency-free HTML templating/DOM rendering library for AEM Edge Delivery Services blocks (and other HTML fragments).
77
- **Public API**: `renderBlock(element, context?)`, `renderElement(element, context?)` exported from `src/index.js` and bundled to `dist/faintly.js`.
8+
- **Security**: Built-in XSS protection via `src/faintly.security.js`, bundled to `dist/faintly.security.js` (dynamically loaded by default).
89

910
### Environment
1011
- **Node**: 20 (CI uses Node 20).
@@ -43,41 +44,60 @@ Authoritative guide for AI/code agents contributing to this repository.
4344
- Keep modules small and readable; avoid deep nesting; avoid unnecessary try/catch.
4445

4546
### Build and artifacts
46-
- Bundling uses `esbuild` to produce a single ESM file at `dist/faintly.js` for browser usage.
47-
- CI enforces a gzipped bundle size limit of **5KB (5120 bytes)**. Keep additions small; avoid adding heavy dependencies.
48-
- If you change source under `src/`, run `npm run build` so `dist/faintly.js` is up to date.
47+
- Bundling uses `esbuild` to produce two ESM bundles for browser usage:
48+
- `dist/faintly.js` (core library, gzipped limit: **4KB / 4096 bytes**)
49+
- `dist/faintly.security.js` (security module, separate to allow tree-shaking)
50+
- CI enforces a **combined gzipped size limit of 6KB (6144 bytes)** for both files.
51+
- Keep additions small; avoid adding heavy dependencies.
52+
- If you change source under `src/`, run `npm run build` so `dist/` artifacts are up to date.
4953

5054
### CI behavior (GitHub Actions)
5155
- Workflow: `.github/workflows/main.yaml` runs on pull requests (open/sync/reopen).
5256
- Steps: checkout → Node 20 → `npm ci``npm run lint``npm test``npm run build:strict`.
5357
- The workflow will attempt to commit updated `dist/` artifacts back to the PR branch if they changed.
5458

5559
### Repo layout
56-
- `src/`: library source (`index.js`, `render.js`, `directives.js`, `expressions.js`, `templates.js`).
57-
- `dist/`: built artifact (`faintly.js`).
58-
- `test/`: unit/perf tests, fixtures, snapshots, and utilities.
59-
- `coverage/`: coverage output when tests are run with coverage.
60+
- `src/`: library source
61+
- Core: `index.js`, `render.js`, `directives.js`, `expressions.js`, `templates.js`
62+
- Security: `faintly.security.js`
63+
- `dist/`: built artifacts (`faintly.js`, `faintly.security.js`)
64+
- `test/`: unit/perf tests, fixtures, snapshots, and utilities
65+
- `test/security/`: tests for security module
66+
- `coverage/`: coverage output when tests are run with coverage
6067

6168
### Contribution checklist for agents
6269
1. Install deps with `npm ci`.
6370
2. Make focused edits under `src/` and relevant tests under `test/`.
6471
3. Run `npm run lint:fix` then `npm run lint` and resolve any remaining issues.
6572
4. Run `npm test` and ensure coverage stays at 100%.
66-
5. Run `npm run build:strict` and verify `dist/faintly.js` updates (if source changed).
67-
6. Ensure gzipped size of `dist/faintly.js` remains <= 5120 bytes (CI will enforce).
73+
5. Run `npm run build:strict` and verify `dist/` artifacts update (if source changed).
74+
6. Ensure combined gzipped size remains <= 6144 bytes (CI will enforce).
6875
7. Update `README.md` if you change public behavior or usage.
6976
8. Commit changes; open a PR. CI will validate and may commit updated `dist/` to the PR branch.
7077

7178
### Public API and usage (for context)
72-
- Consumers copy `dist/faintly.js` into their AEM project and use:
79+
- Consumers copy `dist/faintly.js` and `dist/faintly.security.js` into their AEM project and use:
7380
- `renderBlock(block, context?)`
7481
- `renderElement(element, context?)`
75-
- See `README.md` for examples, directives, and expression syntax.
82+
- Security is **enabled by default** and dynamically loads `faintly.security.js` on first use.
83+
- See `README.md` for examples, directives, expression syntax, and security configuration.
7684

7785
### Guardrails and constraints
7886
- Keep the bundle tiny; avoid adding runtime deps.
7987
- Maintain 100% test coverage; do not reduce thresholds or exclude more files.
8088
- Respect ESM and `.js` extension import rule.
8189
- Do not introduce Node-only APIs into browser code paths.
8290

91+
### Security module (`src/faintly.security.js`)
92+
- Provides default XSS protection: attribute sanitization, URL scheme validation, same-origin enforcement.
93+
- Exported as a separate bundle (`dist/faintly.security.js`) for tree-shaking in opt-out scenarios.
94+
- Dynamically imported by `directives.js` when `context.security` is undefined.
95+
- Users can disable (`security: false`), provide custom hooks, or override default configuration.
96+
- When modifying security:
97+
- **Test thoroughly** - security bugs have serious consequences.
98+
- Use TDD approach with comprehensive test coverage.
99+
- Document changes in `README.md` security section.
100+
- Consider backwards compatibility for existing users.
101+
- Be conservative about what is allowed by default.
102+
83103

README.md

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ I've experimented with other existing libraries (ejs templates, etc.) but wanted
1010

1111
## Getting Started
1212

13-
1. copy the /dist/faintly.js file to the scripts directory of your project
14-
2. in the folder for your block, add a `blockName.html` file for the block template
15-
3. in your block javascript, call the `renderBlock` function:
13+
1. Copy the `/dist/faintly.js` and `/dist/faintly.security.js` files to the scripts directory of your project
14+
2. In the folder for your block, add a `blockName.html` file for the block template
15+
3. In your block javascript, call the `renderBlock` function:
1616

1717
```
1818
import { renderBlock } from '../scripts/faintly.js';
@@ -55,6 +55,7 @@ The rendering context is a javascript object used to provide data to the templat
5555
* template
5656
* path - the path to the template being rendered
5757
* name - the template name, if there is one
58+
* security - security configuration (see Security section below)
5859

5960
When in a repeat loop, it will also include:
6061

@@ -70,6 +71,105 @@ When in a repeat loop, it will also include:
7071
> [!NOTE]
7172
> Because element attributes are case-insensitive, context names are converted to lower case. e.g. `data-fly-test.myTest` will be set in the context as `mytest`.
7273
74+
## Security
75+
76+
Faintly includes built-in security features to help protect against XSS (Cross-Site Scripting) attacks. By default, security is **enabled** and provides:
77+
78+
* **Attribute sanitization** - Blocks dangerous attributes like event handlers (`onclick`, `onerror`, etc.) and `srcdoc`
79+
* **URL scheme validation** - Restricts URLs in attributes like `href` and `src` to safe schemes (`http:`, `https:`, `mailto:`, `tel:`)
80+
* **Same-origin enforcement** - Template includes are restricted to same-origin URLs only
81+
82+
### Default Security
83+
84+
When you call `renderBlock()` without a security context, default security is automatically applied:
85+
86+
```javascript
87+
await renderBlock(block); // Default security enabled
88+
```
89+
90+
The default security module (`dist/faintly.security.js`) is dynamically loaded on first use.
91+
92+
### Custom Security
93+
94+
For more control, you can provide a custom security object with `shouldAllowAttribute` and `allowIncludePath` hooks:
95+
96+
```javascript
97+
await renderBlock(block, {
98+
security: {
99+
shouldAllowAttribute(attrName, value) {
100+
// Return true to allow the attribute, false to block it
101+
// Your custom logic here
102+
return true;
103+
},
104+
allowIncludePath(templatePath) {
105+
// Return true to allow the template include, false to block it
106+
// Your custom logic here
107+
return true;
108+
},
109+
},
110+
});
111+
```
112+
113+
You can also use the default security module and override specific configuration:
114+
115+
```javascript
116+
import createSecurity from './scripts/faintly.security.js';
117+
118+
await renderBlock(block, {
119+
security: createSecurity({
120+
// Add 'data:' URLs to allowed schemes
121+
allowedUrlSchemes: ['http:', 'https:', 'mailto:', 'tel:', 'data:'],
122+
// Block additional attributes
123+
blockedAttributes: ['srcdoc', 'sandbox'],
124+
}),
125+
});
126+
```
127+
128+
### Security Configuration Options
129+
130+
The default security module accepts the following configuration:
131+
132+
* `blockedAttributePatterns` (Array<RegExp>) - Regex patterns for blocked attribute names (default: `/^on/i` blocks all event handlers)
133+
* `blockedAttributes` (Array<string>) - Specific attribute names to block (default: `['srcdoc']`)
134+
* `urlAttributes` (Array<string>) - Attributes that contain URLs to validate (default: `['href', 'src', 'action', 'formaction', 'xlink:href']`)
135+
* `allowedUrlSchemes` (Array<string>) - Allowed URL schemes; relative URLs are always allowed (default: `['http:', 'https:', 'mailto:', 'tel:']`)
136+
137+
138+
### Disabling Security (Unsafe Mode)
139+
140+
You can disable security if needed. **THIS IS NOT RECOMMENDED**
141+
142+
143+
> [!CAUTION]
144+
> **THIS IS NOT RECOMMENDED** and bypasses all XSS protection.
145+
146+
```javascript
147+
await renderBlock(block, {
148+
security: false, // or 'unsafe'
149+
});
150+
```
151+
152+
153+
### Trust Boundaries
154+
155+
It's important to understand what Faintly's security does and doesn't protect:
156+
157+
**Protected:**
158+
- ✅ Dangerous attributes (event handlers, `srcdoc`)
159+
- ✅ Malicious URL schemes (`javascript:`, `data:` by default)
160+
- ✅ Cross-origin template includes
161+
162+
**Trusted (by design):**
163+
- The rendering context you provide is fully trusted
164+
- Templates fetched from your same-origin are trusted
165+
- DOM Node objects provided in context are inserted directly
166+
167+
> [!WARNING]
168+
> **Be extremely careful when adding user-supplied data to the rendering context.** URL parameters, form inputs, cookies, and other user-controlled data should be validated and sanitized before adding to the context. The context is fully trusted, so untrusted data placed in it can bypass security protections.
169+
170+
> [!TIP]
171+
> Security works best in layers. Faintly's security helps prevent common XSS vectors, but you should also: validate and sanitize user input before adding it to context, use Content Security Policy headers, and follow secure coding practices.
172+
73173
## Directives
74174

75175
Faintly supports the following directives.
@@ -101,4 +201,4 @@ For `data-fly-include`, HTML text, and normal attributes, wrap your expression i
101201

102202
Escaping: use a leading backslash to prevent evaluation of an expression in text/attributes, e.g. `\${some.value}` will remain literal `${some.value}`.
103203

104-
In all other `data-fly-*` attributes, just set the expression directly as the attribute value, no wrapping needed.
204+
In all other `data-fly-*` attributes, just set the expression directly as the attribute value, no wrapping needed.

dist/faintly.js

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ var dp = new DOMParser();
33
async function resolveTemplate(context) {
44
context.template = context.template || {};
55
context.template.path = context.template.path || `${context.codeBasePath}/blocks/${context.blockName}/${context.blockName}.html`;
6+
if (context.security && context.template.path) {
7+
const allowed = context.security.allowIncludePath(context.template.path, context);
8+
if (!allowed) {
9+
throw new Error(`Template fetch blocked by security policy: ${context.template.path}`);
10+
}
11+
}
612
const templateId = `faintly-template-${context.template.path}#${context.template.name || ""}`.toLowerCase().replace(/[^0-9a-z]/g, "-");
713
let template = document.getElementById(templateId);
814
if (!template) {
@@ -69,10 +75,13 @@ async function processAttributesDirective(el, context) {
6975
el.removeAttribute("data-fly-attributes");
7076
if (attrsData) {
7177
Object.entries(attrsData).forEach(([k, v]) => {
78+
const name = String(k);
7279
if (v === void 0) {
73-
el.removeAttribute(k);
80+
el.removeAttribute(name);
81+
} else if (context.security.shouldAllowAttribute(name, v, context)) {
82+
el.setAttribute(name, v);
7483
} else {
75-
el.setAttribute(k, v);
84+
el.removeAttribute(name);
7685
}
7786
});
7887
}
@@ -81,7 +90,13 @@ async function processAttributes(el, context) {
8190
await processAttributesDirective(el, context);
8291
const attrPromises = el.getAttributeNames().filter((attrName) => !attrName.startsWith("data-fly-")).map(async (attrName) => {
8392
const { updated, updatedText } = await resolveExpressions(el.getAttribute(attrName), context);
84-
if (updated) el.setAttribute(attrName, updatedText);
93+
if (updated) {
94+
if (context.security.shouldAllowAttribute(attrName, updatedText, context)) {
95+
el.setAttribute(attrName, updatedText);
96+
} else {
97+
el.removeAttribute(attrName);
98+
}
99+
}
85100
});
86101
await Promise.all(attrPromises);
87102
}
@@ -161,6 +176,13 @@ async function processInclude(el, context) {
161176
templatePath = path;
162177
templateName = name;
163178
}
179+
if (templatePath) {
180+
const allowed = context.security.allowIncludePath(templatePath, context);
181+
if (!allowed) {
182+
el.removeAttribute("data-fly-include");
183+
return true;
184+
}
185+
}
164186
const includeContext = {
165187
...context,
166188
template: {
@@ -217,11 +239,31 @@ async function renderTemplate(template, context) {
217239
processUnwraps(templateClone.content);
218240
return templateClone;
219241
}
242+
async function initializeSecurity(context) {
243+
const { security } = context;
244+
if (security === false || security === "unsafe") {
245+
return {
246+
shouldAllowAttribute: (() => true),
247+
allowIncludePath: (() => true)
248+
};
249+
}
250+
if (!security) {
251+
const securityMod = await import("./faintly.security.js");
252+
if (securityMod && securityMod.default) {
253+
return securityMod.default();
254+
}
255+
}
256+
return {
257+
shouldAllowAttribute: security.shouldAllowAttribute || (() => true),
258+
allowIncludePath: security.allowIncludePath || (() => true)
259+
};
260+
}
220261
async function renderElementWithTemplate(el, template, context) {
221262
const rendered = await renderTemplate(template, context);
222263
el.replaceChildren(rendered.content);
223264
}
224265
async function renderElement(el, context) {
266+
context.security = await initializeSecurity(context);
225267
const template = await resolveTemplate(context);
226268
await renderElementWithTemplate(el, template, context);
227269
}

dist/faintly.security.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// src/faintly.security.js
2+
var DEFAULT_CONFIG = {
3+
blockedAttributePatterns: [/^on/i],
4+
blockedAttributes: ["srcdoc"],
5+
urlAttributes: ["href", "src", "action", "formaction", "xlink:href"],
6+
allowedUrlSchemes: ["http:", "https:", "mailto:", "tel:"]
7+
};
8+
function isBlockedAttribute(attrName, blockedAttributePatterns, blockedAttributes) {
9+
const name = attrName.toLowerCase();
10+
return blockedAttributes.includes(name) || blockedAttributePatterns.some((pattern) => pattern.test(name));
11+
}
12+
function extractUrlScheme(value) {
13+
const v = value.trim();
14+
if (!v) return window.location.protocol;
15+
const colonIndex = v.indexOf(":");
16+
const slashIndex = v.indexOf("/");
17+
if (colonIndex === -1 || slashIndex !== -1 && colonIndex > slashIndex) {
18+
return window.location.protocol;
19+
}
20+
const url = new URL(v, window.location.origin);
21+
return url.protocol;
22+
}
23+
function isUrlAttribute(attrName, urlAttributes) {
24+
return urlAttributes.includes(attrName.toLowerCase());
25+
}
26+
function createSecurity(config = {}) {
27+
const mergedConfig = {
28+
...DEFAULT_CONFIG,
29+
...config
30+
};
31+
const {
32+
blockedAttributePatterns,
33+
blockedAttributes,
34+
urlAttributes,
35+
allowedUrlSchemes
36+
} = mergedConfig;
37+
return {
38+
shouldAllowAttribute(attrName, value) {
39+
if (isBlockedAttribute(attrName, blockedAttributePatterns, blockedAttributes)) {
40+
return false;
41+
}
42+
if (isUrlAttribute(attrName, urlAttributes)) {
43+
const scheme = extractUrlScheme(value);
44+
return allowedUrlSchemes.includes(scheme);
45+
}
46+
return true;
47+
},
48+
allowIncludePath(templatePath) {
49+
if (!templatePath) {
50+
return true;
51+
}
52+
const templateUrl = new URL(templatePath, window.location.origin);
53+
return templateUrl.origin === window.location.origin;
54+
}
55+
};
56+
}
57+
export {
58+
DEFAULT_CONFIG,
59+
createSecurity as default
60+
};

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "faintly",
3-
"version": "1.0.1",
3+
"version": "1.1.0",
44
"description": "HTML Markup Transformation Library for AEM Blocks",
55
"main": "src/index.js",
66
"scripts": {

0 commit comments

Comments
 (0)