Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ff87d41
cleaned up some comments
shsteimer Oct 28, 2025
fd363cc
fix(render): skip child traversal when include runs; content takes pr…
shsteimer Oct 28, 2025
ba7e458
test(render): add traversal tests and fixtures for include vs content…
shsteimer Oct 28, 2025
b59c6fc
chore(templates): remove redundant case-insensitive flag in template …
shsteimer Oct 28, 2025
5e0b902
docs: document content vs include precedence, traversal behavior, and…
shsteimer Oct 28, 2025
d571048
build: update dist/faintly.js
shsteimer Oct 28, 2025
b56d0bd
docs(agents): use build:strict in checklist; add size-check scripts a…
shsteimer Oct 28, 2025
1f57a3b
chore(dev): update dev tooling and apply security overrides\n\n- @web…
shsteimer Oct 28, 2025
5a592c3
chore: deps updates
shsteimer Oct 28, 2025
21e582d
build: use .mjs scripts with watch and size-check; lint: include .mjs…
shsteimer Oct 29, 2025
ce336b2
feat(security): default config, include path policy via , dynamic imp…
shsteimer Oct 29, 2025
9b95a42
Merge branch 'main' into feat/security-hardening
shsteimer Oct 29, 2025
5fee57a
Refactor size check logic in build scripts
shsteimer Oct 29, 2025
8c298b3
Phase 1: Clean slate with directives.js security integration
shsteimer Oct 29, 2025
17b9d02
Implement shouldAllowAttribute with TDD approach
shsteimer Oct 29, 2025
166d54f
Remove inline comments from DEFAULT_CONFIG
shsteimer Oct 29, 2025
54759a4
Unskip default security test for processAttributes
shsteimer Oct 29, 2025
3dbadf8
Refactor to allowedTemplatePaths and unskip all tests
shsteimer Oct 29, 2025
35ac247
Simplify allowIncludePath to same-origin only
shsteimer Oct 29, 2025
668db23
Add comprehensive security documentation to README
shsteimer Oct 29, 2025
6bb84eb
Update AGENTS.md with security module documentation
shsteimer Oct 29, 2025
f58d2a1
Refactor security initialization to renderElement
shsteimer Oct 29, 2025
75985c3
Bump version to 1.1.0 for security feature release
shsteimer Oct 29, 2025
e96b272
Address PR feedback: improve consistency
shsteimer Oct 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 31 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Authoritative guide for AI/code agents contributing to this repository.
### Project purpose
- **What this is**: A small, dependency-free HTML templating/DOM rendering library for AEM Edge Delivery Services blocks (and other HTML fragments).
- **Public API**: `renderBlock(element, context?)`, `renderElement(element, context?)` exported from `src/index.js` and bundled to `dist/faintly.js`.
- **Security**: Built-in XSS protection via `src/faintly.security.js`, bundled to `dist/faintly.security.js` (dynamically loaded by default).

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

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

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

### Repo layout
- `src/`: library source (`index.js`, `render.js`, `directives.js`, `expressions.js`, `templates.js`).
- `dist/`: built artifact (`faintly.js`).
- `test/`: unit/perf tests, fixtures, snapshots, and utilities.
- `coverage/`: coverage output when tests are run with coverage.
- `src/`: library source
- Core: `index.js`, `render.js`, `directives.js`, `expressions.js`, `templates.js`
- Security: `faintly.security.js`
- `dist/`: built artifacts (`faintly.js`, `faintly.security.js`)
- `test/`: unit/perf tests, fixtures, snapshots, and utilities
- `test/security/`: tests for security module
- `coverage/`: coverage output when tests are run with coverage

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

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

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

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


108 changes: 104 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ I've experimented with other existing libraries (ejs templates, etc.) but wanted

## Getting Started

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

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

When in a repeat loop, it will also include:

Expand All @@ -70,6 +71,105 @@ When in a repeat loop, it will also include:
> [!NOTE]
> 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`.

## Security

Faintly includes built-in security features to help protect against XSS (Cross-Site Scripting) attacks. By default, security is **enabled** and provides:

* **Attribute sanitization** - Blocks dangerous attributes like event handlers (`onclick`, `onerror`, etc.) and `srcdoc`
* **URL scheme validation** - Restricts URLs in attributes like `href` and `src` to safe schemes (`http:`, `https:`, `mailto:`, `tel:`)
* **Same-origin enforcement** - Template includes are restricted to same-origin URLs only

### Default Security

When you call `renderBlock()` without a security context, default security is automatically applied:

```javascript
await renderBlock(block); // Default security enabled
```

The default security module (`dist/faintly.security.js`) is dynamically loaded on first use.

### Custom Security

For more control, you can provide a custom security object with `shouldAllowAttribute` and `allowIncludePath` hooks:

```javascript
await renderBlock(block, {
security: {
shouldAllowAttribute(attrName, value) {
// Return true to allow the attribute, false to block it
// Your custom logic here
return true;
},
allowIncludePath(templatePath) {
// Return true to allow the template include, false to block it
// Your custom logic here
return true;
},
},
});
```

You can also use the default security module and override specific configuration:

```javascript
import createSecurity from './scripts/faintly.security.js';

await renderBlock(block, {
security: createSecurity({
// Add 'data:' URLs to allowed schemes
allowedUrlSchemes: ['http:', 'https:', 'mailto:', 'tel:', 'data:'],
// Block additional attributes
blockedAttributes: ['srcdoc', 'sandbox'],
}),
});
```

### Security Configuration Options

The default security module accepts the following configuration:

* `blockedAttributePatterns` (Array<RegExp>) - Regex patterns for blocked attribute names (default: `/^on/i` blocks all event handlers)
* `blockedAttributes` (Array<string>) - Specific attribute names to block (default: `['srcdoc']`)
* `urlAttributes` (Array<string>) - Attributes that contain URLs to validate (default: `['href', 'src', 'action', 'formaction', 'xlink:href']`)
* `allowedUrlSchemes` (Array<string>) - Allowed URL schemes; relative URLs are always allowed (default: `['http:', 'https:', 'mailto:', 'tel:']`)


### Disabling Security (Unsafe Mode)

You can disable security if needed. **THIS IS NOT RECOMMENDED**


> [!CAUTION]
> **THIS IS NOT RECOMMENDED** and bypasses all XSS protection.

```javascript
await renderBlock(block, {
security: false, // or 'unsafe'
});
```


### Trust Boundaries

It's important to understand what Faintly's security does and doesn't protect:

**Protected:**
- βœ… Dangerous attributes (event handlers, `srcdoc`)
- βœ… Malicious URL schemes (`javascript:`, `data:` by default)
- βœ… Cross-origin template includes

**Trusted (by design):**
- The rendering context you provide is fully trusted
- Templates fetched from your same-origin are trusted
- DOM Node objects provided in context are inserted directly

> [!WARNING]
> **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.

> [!TIP]
> 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.

## Directives

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

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

In all other `data-fly-*` attributes, just set the expression directly as the attribute value, no wrapping needed.
In all other `data-fly-*` attributes, just set the expression directly as the attribute value, no wrapping needed.
48 changes: 45 additions & 3 deletions dist/faintly.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ var dp = new DOMParser();
async function resolveTemplate(context) {
context.template = context.template || {};
context.template.path = context.template.path || `${context.codeBasePath}/blocks/${context.blockName}/${context.blockName}.html`;
if (context.security && context.template.path) {
const allowed = context.security.allowIncludePath(context.template.path, context);
if (!allowed) {
throw new Error(`Template fetch blocked by security policy: ${context.template.path}`);
}
}
const templateId = `faintly-template-${context.template.path}#${context.template.name || ""}`.toLowerCase().replace(/[^0-9a-z]/g, "-");
let template = document.getElementById(templateId);
if (!template) {
Expand Down Expand Up @@ -69,10 +75,13 @@ async function processAttributesDirective(el, context) {
el.removeAttribute("data-fly-attributes");
if (attrsData) {
Object.entries(attrsData).forEach(([k, v]) => {
const name = String(k);
if (v === void 0) {
el.removeAttribute(k);
el.removeAttribute(name);
} else if (context.security.shouldAllowAttribute(name, v, context)) {
el.setAttribute(name, v);
} else {
el.setAttribute(k, v);
el.removeAttribute(name);
}
});
}
Expand All @@ -81,7 +90,13 @@ async function processAttributes(el, context) {
await processAttributesDirective(el, context);
const attrPromises = el.getAttributeNames().filter((attrName) => !attrName.startsWith("data-fly-")).map(async (attrName) => {
const { updated, updatedText } = await resolveExpressions(el.getAttribute(attrName), context);
if (updated) el.setAttribute(attrName, updatedText);
if (updated) {
if (context.security.shouldAllowAttribute(attrName, updatedText, context)) {
el.setAttribute(attrName, updatedText);
} else {
el.removeAttribute(attrName);
}
}
});
await Promise.all(attrPromises);
}
Expand Down Expand Up @@ -161,6 +176,13 @@ async function processInclude(el, context) {
templatePath = path;
templateName = name;
}
if (templatePath) {
const allowed = context.security.allowIncludePath(templatePath, context);
if (!allowed) {
el.removeAttribute("data-fly-include");
return true;
}
}
const includeContext = {
...context,
template: {
Expand Down Expand Up @@ -217,11 +239,31 @@ async function renderTemplate(template, context) {
processUnwraps(templateClone.content);
return templateClone;
}
async function initializeSecurity(context) {
const { security } = context;
if (security === false || security === "unsafe") {
return {
shouldAllowAttribute: (() => true),
allowIncludePath: (() => true)
};
}
if (!security) {
const securityMod = await import("./faintly.security.js");
if (securityMod && securityMod.default) {
return securityMod.default();
}
}
return {
shouldAllowAttribute: security.shouldAllowAttribute || (() => true),
allowIncludePath: security.allowIncludePath || (() => true)
};
}
async function renderElementWithTemplate(el, template, context) {
const rendered = await renderTemplate(template, context);
el.replaceChildren(rendered.content);
}
async function renderElement(el, context) {
context.security = await initializeSecurity(context);
const template = await resolveTemplate(context);
await renderElementWithTemplate(el, template, context);
}
Expand Down
60 changes: 60 additions & 0 deletions dist/faintly.security.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// src/faintly.security.js
var DEFAULT_CONFIG = {
blockedAttributePatterns: [/^on/i],
blockedAttributes: ["srcdoc"],
urlAttributes: ["href", "src", "action", "formaction", "xlink:href"],
allowedUrlSchemes: ["http:", "https:", "mailto:", "tel:"]
};
function isBlockedAttribute(attrName, blockedAttributePatterns, blockedAttributes) {
const name = attrName.toLowerCase();
return blockedAttributes.includes(name) || blockedAttributePatterns.some((pattern) => pattern.test(name));
}
function extractUrlScheme(value) {
const v = value.trim();
if (!v) return window.location.protocol;
const colonIndex = v.indexOf(":");
const slashIndex = v.indexOf("/");
if (colonIndex === -1 || slashIndex !== -1 && colonIndex > slashIndex) {
return window.location.protocol;
}
const url = new URL(v, window.location.origin);
return url.protocol;
}
function isUrlAttribute(attrName, urlAttributes) {
return urlAttributes.includes(attrName.toLowerCase());
}
function createSecurity(config = {}) {
const mergedConfig = {
...DEFAULT_CONFIG,
...config
};
const {
blockedAttributePatterns,
blockedAttributes,
urlAttributes,
allowedUrlSchemes
} = mergedConfig;
return {
shouldAllowAttribute(attrName, value) {
if (isBlockedAttribute(attrName, blockedAttributePatterns, blockedAttributes)) {
return false;
}
if (isUrlAttribute(attrName, urlAttributes)) {
const scheme = extractUrlScheme(value);
return allowedUrlSchemes.includes(scheme);
}
return true;
},
allowIncludePath(templatePath) {
if (!templatePath) {
return true;
}
const templateUrl = new URL(templatePath, window.location.origin);
return templateUrl.origin === window.location.origin;
}
};
}
export {
DEFAULT_CONFIG,
createSecurity as default
};
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "faintly",
"version": "1.0.1",
"version": "1.1.0",
"description": "HTML Markup Transformation Library for AEM Blocks",
"main": "src/index.js",
"scripts": {
Expand Down
Loading