diff --git a/AGENTS.md b/AGENTS.md index f45fb97..95ecb49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). @@ -43,9 +44,12 @@ 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). @@ -53,26 +57,30 @@ Authoritative guide for AI/code agents contributing to this repository. - 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. @@ -80,4 +88,16 @@ Authoritative guide for AI/code agents contributing to this repository. - 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. + diff --git a/README.md b/README.md index 2ee25fc..31d271d 100644 --- a/README.md +++ b/README.md @@ -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'; @@ -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: @@ -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) - Regex patterns for blocked attribute names (default: `/^on/i` blocks all event handlers) +* `blockedAttributes` (Array) - Specific attribute names to block (default: `['srcdoc']`) +* `urlAttributes` (Array) - Attributes that contain URLs to validate (default: `['href', 'src', 'action', 'formaction', 'xlink:href']`) +* `allowedUrlSchemes` (Array) - 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. @@ -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. \ No newline at end of file +In all other `data-fly-*` attributes, just set the expression directly as the attribute value, no wrapping needed. diff --git a/dist/faintly.js b/dist/faintly.js index 29f168d..0090cc6 100644 --- a/dist/faintly.js +++ b/dist/faintly.js @@ -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) { @@ -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); } }); } @@ -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); } @@ -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: { @@ -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); } diff --git a/dist/faintly.security.js b/dist/faintly.security.js new file mode 100644 index 0000000..c7581cb --- /dev/null +++ b/dist/faintly.security.js @@ -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 +}; diff --git a/package.json b/package.json index 6e2e477..4d16147 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/directives.js b/src/directives.js index 1c91129..f6a5ca4 100644 --- a/src/directives.js +++ b/src/directives.js @@ -11,10 +11,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 === undefined) { - 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); } }); } @@ -33,7 +36,13 @@ export async function processAttributes(el, context) { .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); } @@ -170,6 +179,15 @@ export async function processInclude(el, context) { templateName = name; } + // Enforce include path restrictions: same-origin and within allowed base path + if (templatePath) { + const allowed = context.security.allowIncludePath(templatePath, context); + if (!allowed) { + el.removeAttribute('data-fly-include'); + return true; + } + } + const includeContext = { ...context, template: { diff --git a/src/faintly.security.js b/src/faintly.security.js new file mode 100644 index 0000000..5f6d165 --- /dev/null +++ b/src/faintly.security.js @@ -0,0 +1,110 @@ +/** + * Default security configuration. + * Users can see and override these defaults by passing custom values to createSecurity(). + */ +export const DEFAULT_CONFIG = { + blockedAttributePatterns: [/^on/i], + blockedAttributes: ['srcdoc'], + urlAttributes: ['href', 'src', 'action', 'formaction', 'xlink:href'], + allowedUrlSchemes: ['http:', 'https:', 'mailto:', 'tel:'], +}; + +/** + * Check if an attribute name is blocked by pattern or specific name + * @param {string} attrName The attribute name to check + * @param {Array} blockedAttributePatterns Regex patterns to test against + * @param {Array} blockedAttributes Specific attribute names to block + * @returns {boolean} True if the attribute is blocked + */ +function isBlockedAttribute(attrName, blockedAttributePatterns, blockedAttributes) { + const name = attrName.toLowerCase(); + + return blockedAttributes.includes(name) + || blockedAttributePatterns.some((pattern) => pattern.test(name)); +} + +/** + * Extract URL scheme from a value + * @param {string} value The URL value to parse + * @returns {string} The URL scheme (e.g., 'http:', 'javascript:'). + * Relative URLs return the current page's protocol. + */ +function extractUrlScheme(value) { + const v = value.trim(); + if (!v) return window.location.protocol; + + const colonIndex = v.indexOf(':'); + const slashIndex = v.indexOf('/'); + + // No colon, or colon after slash = relative URL (use current protocol) + if (colonIndex === -1 || (slashIndex !== -1 && colonIndex > slashIndex)) { + return window.location.protocol; + } + + const url = new URL(v, window.location.origin); + return url.protocol; +} + +/** + * Check if an attribute is a URL attribute + * @param {string} attrName The attribute name to check + * @param {Array} urlAttributes List of attributes that contain URLs + * @returns {boolean} True if the attribute is a URL attribute + */ +function isUrlAttribute(attrName, urlAttributes) { + return urlAttributes.includes(attrName.toLowerCase()); +} + +/** + * Create a security policy for Faintly. + * Pass custom configuration to override defaults. + * + * @param {Object} [config] Custom security configuration + * @param {Array} [config.blockedAttributePatterns] + * Regex patterns for blocked attribute names + * @param {Array} [config.blockedAttributes] + * Array of specific attribute names to block + * @param {Array} [config.urlAttributes] + * Attributes to check for URL scheme validation + * @param {Array} [config.allowedUrlSchemes] + * Array of allowed URL schemes (e.g., ['http:', 'https:']). + * Relative URLs are always allowed. + * @returns {Object} Security hooks + */ +export default 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; + }, + }; +} diff --git a/src/render.js b/src/render.js index 5782946..de914ea 100644 --- a/src/render.js +++ b/src/render.js @@ -66,6 +66,37 @@ export async function renderTemplate(template, context) { return templateClone; } +/** + * Initialize security for the rendering context + * @param {Object} context the rendering context + * @returns {Promise} security hooks + */ +export async function initializeSecurity(context) { + const { security } = context; + + // unsafe mode + if (security === false || security === 'unsafe') { + return { + shouldAllowAttribute: (() => true), + allowIncludePath: (() => true), + }; + } + + // default mode + if (!security) { + const securityMod = await import('./faintly.security.js'); + if (securityMod && securityMod.default) { + return securityMod.default(); + } + } + + // custom mode, ensure needed functions are present, use no-ops for missing ones + return { + shouldAllowAttribute: security.shouldAllowAttribute || (() => true), + allowIncludePath: security.allowIncludePath || (() => true), + }; +} + /** * transform the element, replacing it's children with the content from the template * @param {Element} el the element @@ -84,6 +115,8 @@ export async function renderElementWithTemplate(el, template, context) { * @param {Object} context the rendering context */ export async function renderElement(el, context) { + context.security = await initializeSecurity(context); + const template = await resolveTemplate(context); await renderElementWithTemplate(el, template, context); diff --git a/src/templates.js b/src/templates.js index 62a6be0..672480c 100644 --- a/src/templates.js +++ b/src/templates.js @@ -10,6 +10,14 @@ export default async function resolveTemplate(context) { context.template = context.template || {}; context.template.path = context.template.path || `${context.codeBasePath}/blocks/${context.blockName}/${context.blockName}.html`; + // Enforce template path security before fetching + 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) { diff --git a/test/directives/attributes/processAttributes.test.js b/test/directives/attributes/processAttributes.test.js index 68cab34..378cb5f 100644 --- a/test/directives/attributes/processAttributes.test.js +++ b/test/directives/attributes/processAttributes.test.js @@ -3,13 +3,16 @@ import { expect } from '@esm-bundle/chai'; import { processAttributes } from '../../../src/directives.js'; +import { initializeSecurity } from '../../../src/render.js'; describe('processAttributes', () => { it('resolves expressions in non data-fly-* attributes', async () => { const el = document.createElement('div'); + const context = { divClass: 'some-class' }; + context.security = await initializeSecurity(context); // eslint-disable-next-line no-template-curly-in-string el.setAttribute('class', '${ divClass }'); - await processAttributes(el, { divClass: 'some-class' }); + await processAttributes(el, context); expect(el.getAttribute('class')).to.equal('some-class'); }); @@ -17,13 +20,15 @@ describe('processAttributes', () => { const el = document.createElement('div'); el.setAttribute('class', 'some-class'); el.setAttribute('data-fly-attributes', 'divAttrs'); - await processAttributes(el, { + const context = { divAttrs: { class: 'some-other-class', id: 'some-id', 'aria-label': 'some-label', }, - }); + }; + context.security = await initializeSecurity(context); + await processAttributes(el, context); expect(el.getAttribute('class')).to.equal('some-other-class'); expect(el.getAttribute('id')).to.equal('some-id'); expect(el.getAttribute('aria-label')).to.equal('some-label'); @@ -33,18 +38,118 @@ describe('processAttributes', () => { const el = document.createElement('div'); el.setAttribute('class', 'some-class'); el.setAttribute('data-fly-attributes', 'divAttrs'); - await processAttributes(el, { + const context = { divAttrs: { class: undefined, }, - }); + }; + context.security = await initializeSecurity(context); + await processAttributes(el, context); expect(el.hasAttribute('class')).to.equal(false); }); it('removes attributes directive when complete', async () => { const el = document.createElement('div'); el.setAttribute('data-fly-attributes', ''); - await processAttributes(el); + const context = {}; + context.security = await initializeSecurity(context); + await processAttributes(el, context); expect(el.hasAttribute('data-fly-attributes')).to.equal(false); }); + + describe('security integration', () => { + it('calls security hooks when context.security is provided with custom hooks', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-attributes', 'attrs'); + + let shouldAllowCalled = false; + const customSecurity = { + shouldAllowAttribute: (name) => { + shouldAllowCalled = true; + return name !== 'blocked'; + }, + allowIncludePath: () => true, + }; + + const context = { + attrs: { allowed: 'value', blocked: 'value' }, + security: customSecurity, + }; + context.security = await initializeSecurity(context); + await processAttributes(el, context); + + expect(shouldAllowCalled).to.equal(true); + expect(el.getAttribute('allowed')).to.equal('value'); + expect(el.hasAttribute('blocked')).to.equal(false); + }); + + it('allows all attributes in unsafe mode with security: false', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-attributes', 'attrs'); + + const context = { + // eslint-disable-next-line no-script-url + attrs: { onclick: 'alert(1)', href: 'javascript:void(0)' }, + security: false, + }; + context.security = await initializeSecurity(context); + await processAttributes(el, context); + + expect(el.getAttribute('onclick')).to.equal('alert(1)'); + // eslint-disable-next-line no-script-url + expect(el.getAttribute('href')).to.equal('javascript:void(0)'); + }); + + it('allows all attributes in unsafe mode with security: "unsafe"', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-attributes', 'attrs'); + + const context = { + // eslint-disable-next-line no-script-url + attrs: { onclick: 'alert(1)', href: 'javascript:void(0)' }, + security: 'unsafe', + }; + context.security = await initializeSecurity(context); + await processAttributes(el, context); + + expect(el.getAttribute('onclick')).to.equal('alert(1)'); + // eslint-disable-next-line no-script-url + expect(el.getAttribute('href')).to.equal('javascript:void(0)'); + }); + + it('loads default security module when no security context provided', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-attributes', 'attrs'); + + const context = { + attrs: { onclick: 'alert(1)', class: 'safe' }, + }; + context.security = await initializeSecurity(context); + // Default security should block event handlers + await processAttributes(el, context); + + expect(el.hasAttribute('onclick')).to.equal(false); // Blocked by default security + expect(el.getAttribute('class')).to.equal('safe'); // Safe attribute allowed + }); + + it('applies security checks to expression-resolved attributes', async () => { + const el = document.createElement('a'); + // eslint-disable-next-line no-template-curly-in-string + el.setAttribute('href', '${ link }'); + + const customSecurity = { + shouldAllowAttribute: (name, value) => value !== 'blocked-value', + allowIncludePath: () => true, + }; + + const context = { + link: 'blocked-value', + security: customSecurity, + }; + context.security = await initializeSecurity(context); + await processAttributes(el, context); + + expect(el.hasAttribute('href')).to.equal(false); + }); + }); }); diff --git a/test/directives/include/processInclude.test.js b/test/directives/include/processInclude.test.js index 98fea1c..9a6e426 100644 --- a/test/directives/include/processInclude.test.js +++ b/test/directives/include/processInclude.test.js @@ -2,24 +2,29 @@ /* eslint-disable no-unused-expressions */ import { expect } from '@esm-bundle/chai'; import { processInclude } from '../../../src/directives.js'; +import { initializeSecurity } from '../../../src/render.js'; import { compareDom } from '../../test-utils.js'; describe('processInclude', () => { it('returns false when the directive is absent', async () => { const el = document.createElement('div'); el.textContent = 'Some text'; - const result = await processInclude(el); + const context = {}; + context.security = await initializeSecurity(context); + const result = await processInclude(el, context); expect(result).to.equal(false); }); it('replaces elements from a template by name', async () => { const el = document.createElement('div'); el.setAttribute('data-fly-include', 'static-alt'); - const result = await processInclude(el, { + const context = { template: { path: '/test/fixtures/blocks/static-block/static-block.html', }, - }); + }; + context.security = await initializeSecurity(context); + const result = await processInclude(el, context); expect(result).to.equal(true); await compareDom(el, 'templates/static-block-alt'); }); @@ -27,33 +32,130 @@ describe('processInclude', () => { it('replaces elements from a template by path', async () => { const el = document.createElement('div'); el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); - await processInclude(el, { + const context = { template: { path: '/test/fixtures/blocks/static-block/static-block.html', }, - }); + }; + context.security = await initializeSecurity(context); + await processInclude(el, context); await compareDom(el, 'templates/static-block-custom-template'); }); it('replaces elements from a template by name and path', async () => { const el = document.createElement('div'); el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html#custom-alt'); - await processInclude(el, { + const context = { template: { path: '/test/fixtures/blocks/static-block/static-block.html', }, - }); + }; + context.security = await initializeSecurity(context); + await processInclude(el, context); await compareDom(el, 'templates/static-block-custom-named-template'); }); it('removes include directive when complete', async () => { const el = document.createElement('div'); el.setAttribute('data-fly-include', ''); - await processInclude(el, { + const context = { template: { path: '/test/fixtures/blocks/static-block/static-block.html', }, - }); + }; + context.security = await initializeSecurity(context); + await processInclude(el, context); expect(el.hasAttribute('data-fly-include')).to.equal(false); }); + + describe('security integration', () => { + it('calls allowIncludePath hook when security context is provided', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); + + let allowIncludePathCalled = false; + const customSecurity = { + shouldAllowAttribute: () => true, + allowIncludePath: (path) => { + allowIncludePathCalled = true; + return path.startsWith('/test/fixtures'); + }, + }; + + const context = { + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + security: customSecurity, + }; + context.security = await initializeSecurity(context); + await processInclude(el, context); + + expect(allowIncludePathCalled).to.equal(true); + expect(el.hasAttribute('data-fly-include')).to.equal(false); + }); + + it('blocks include when allowIncludePath returns false', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/blocked/path/template.html'); + + const customSecurity = { + shouldAllowAttribute: () => true, + allowIncludePath: () => false, + }; + + const context = { + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + security: customSecurity, + }; + context.security = await initializeSecurity(context); + await processInclude(el, context); + + expect(el.hasAttribute('data-fly-include')).to.equal(false); + expect(el.childNodes.length).to.equal(0); + }); + + it('allows includes in unsafe mode with security: false', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); + + const context = { + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + security: false, + }; + context.security = await initializeSecurity(context); + await processInclude(el, context); + + expect(el.hasAttribute('data-fly-include')).to.equal(false); + expect(el.childNodes.length).to.be.greaterThan(0); + }); + + it('allows includes in unsafe mode with security: "unsafe"', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); + + const context = { + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + security: 'unsafe', + }; + context.security = await initializeSecurity(context); + await processInclude(el, context); + + expect(el.hasAttribute('data-fly-include')).to.equal(false); + expect(el.childNodes.length).to.be.greaterThan(0); + }); + + it('loads default security module and allows same-origin paths', async () => { + const el = document.createElement('div'); + el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/custom-template.html'); + + const context = { + template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, + }; + context.security = await initializeSecurity(context); + // Default security allows all same-origin paths + await processInclude(el, context); + + expect(el.hasAttribute('data-fly-include')).to.equal(false); + expect(el.childNodes.length).to.be.greaterThan(0); + }); + }); }); diff --git a/test/render/includeChildProcessing.test.js b/test/render/includeChildProcessing.test.js index bf5a225..9cdefc7 100644 --- a/test/render/includeChildProcessing.test.js +++ b/test/render/includeChildProcessing.test.js @@ -2,7 +2,7 @@ /* eslint-disable no-unused-expressions, no-template-curly-in-string */ import { expect } from '@esm-bundle/chai'; -import { processNode } from '../../src/render.js'; +import { processNode, initializeSecurity } from '../../src/render.js'; describe('render/processNode include child processing', () => { it('does not reprocess included children expressions (escaped remains literal)', async () => { @@ -11,10 +11,12 @@ describe('render/processNode include child processing', () => { el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/escaped-expression.html#escaped'); wrapper.append(el); - await processNode(wrapper, { + const context = { shouldNotResolve: 'WILL_RESOLVE_IF_BUG_PRESENT', template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, - }); + }; + context.security = await initializeSecurity(context); + await processNode(wrapper, context); const inner = wrapper.querySelector('.inner'); expect(inner).to.not.be.null; @@ -27,10 +29,12 @@ describe('render/processNode include child processing', () => { el.setAttribute('data-fly-include', '/test/fixtures/blocks/static-block/resolvable-expression.html#resolvable'); wrapper.append(el); - await processNode(wrapper, { + const context = { shouldResolve: 'OK', template: { path: '/test/fixtures/blocks/static-block/static-block.html' }, - }); + }; + context.security = await initializeSecurity(context); + await processNode(wrapper, context); const inner = wrapper.querySelector('.inner'); expect(inner).to.not.be.null; diff --git a/test/security/createSecurity.test.js b/test/security/createSecurity.test.js new file mode 100644 index 0000000..a31e4d6 --- /dev/null +++ b/test/security/createSecurity.test.js @@ -0,0 +1,173 @@ +/* eslint-env mocha */ +/* eslint-disable no-unused-expressions, no-script-url */ + +import { expect } from '@esm-bundle/chai'; +import createSecurity from '../../src/faintly.security.js'; + +describe('createSecurity', () => { + describe('shouldAllowAttribute - blocked attributes', () => { + it('blocks event handler attributes by pattern (onclick, onload, etc.)', () => { + const security = createSecurity(); + + expect(security.shouldAllowAttribute('onclick', 'alert(1)')).to.equal(false); + expect(security.shouldAllowAttribute('onload', 'doSomething()')).to.equal(false); + expect(security.shouldAllowAttribute('ONCLICK', 'alert(1)')).to.equal(false); // case insensitive + expect(security.shouldAllowAttribute('OnMouseOver', 'hack()')).to.equal(false); + }); + + it('blocks srcdoc attribute', () => { + const security = createSecurity(); + + expect(security.shouldAllowAttribute('srcdoc', '')).to.equal(false); + }); + + it('allows normal attributes', () => { + const security = createSecurity(); + + expect(security.shouldAllowAttribute('class', 'some-class')).to.equal(true); + expect(security.shouldAllowAttribute('id', 'some-id')).to.equal(true); + expect(security.shouldAllowAttribute('aria-label', 'description')).to.equal(true); + expect(security.shouldAllowAttribute('data-test', 'value')).to.equal(true); + }); + }); + + describe('shouldAllowAttribute - URL validation', () => { + it('blocks javascript: URLs in href', () => { + const security = createSecurity(); + + expect(security.shouldAllowAttribute('href', 'javascript:alert(1)')).to.equal(false); + }); + + it('blocks javascript: URLs in src', () => { + const security = createSecurity(); + + expect(security.shouldAllowAttribute('src', 'javascript:alert(1)')).to.equal(false); + }); + + it('blocks data: URLs by default', () => { + const security = createSecurity(); + + expect(security.shouldAllowAttribute('href', 'data:text/html,')).to.equal(false); + }); + + it('allows http: and https: URLs', () => { + const security = createSecurity(); + + expect(security.shouldAllowAttribute('href', 'http://example.com')).to.equal(true); + expect(security.shouldAllowAttribute('href', 'https://example.com')).to.equal(true); + expect(security.shouldAllowAttribute('src', 'https://cdn.example.com/image.jpg')).to.equal(true); + }); + + it('allows mailto: and tel: URLs', () => { + const security = createSecurity(); + + expect(security.shouldAllowAttribute('href', 'mailto:test@example.com')).to.equal(true); + expect(security.shouldAllowAttribute('href', 'tel:+1234567890')).to.equal(true); + }); + + it('always allows relative URLs', () => { + const security = createSecurity(); + + expect(security.shouldAllowAttribute('href', '/path/to/page')).to.equal(true); + expect(security.shouldAllowAttribute('href', './relative')).to.equal(true); + expect(security.shouldAllowAttribute('href', '../parent')).to.equal(true); + expect(security.shouldAllowAttribute('href', '#hash')).to.equal(true); + expect(security.shouldAllowAttribute('href', '?query=value')).to.equal(true); + }); + + it('does not apply URL validation to non-URL attributes', () => { + const security = createSecurity(); + + // These should be allowed even though they contain "javascript:" + expect(security.shouldAllowAttribute('title', 'javascript: is dangerous')).to.equal(true); + expect(security.shouldAllowAttribute('alt', 'data:image/png')).to.equal(true); + }); + + it('handles edge cases in URL scheme extraction', () => { + const security = createSecurity(); + + // Empty or whitespace values + expect(security.shouldAllowAttribute('href', '')).to.equal(true); + expect(security.shouldAllowAttribute('href', ' ')).to.equal(true); + + // Colon after slash (not a scheme) + expect(security.shouldAllowAttribute('href', '/path:with:colons')).to.equal(true); + expect(security.shouldAllowAttribute('href', 'path/file:name')).to.equal(true); + + // String with colon before slash is parsed as scheme (even if unusual) + // Should be blocked since 'not:' is not in allowedUrlSchemes + expect(security.shouldAllowAttribute('href', 'not:a:valid:url')).to.equal(false); + }); + }); + + describe('shouldAllowAttribute - custom configuration', () => { + it('allows overriding blockedAttributes', () => { + const security = createSecurity({ + blockedAttributes: ['style', 'class'], + }); + + expect(security.shouldAllowAttribute('style', 'color:red')).to.equal(false); + expect(security.shouldAllowAttribute('class', 'test')).to.equal(false); + expect(security.shouldAllowAttribute('srcdoc', 'test')).to.equal(true); // Original default not included + }); + + it('allows overriding blockedAttributePatterns', () => { + const security = createSecurity({ + blockedAttributePatterns: [/^data-/i], + }); + + expect(security.shouldAllowAttribute('data-test', 'value')).to.equal(false); + expect(security.shouldAllowAttribute('onclick', 'alert(1)')).to.equal(true); // Original default not included + }); + + it('allows adding to allowedUrlSchemes', () => { + const dataGif = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='; + const security = createSecurity({ + allowedUrlSchemes: ['http:', 'https:', 'mailto:', 'tel:', 'data:'], + }); + + expect(security.shouldAllowAttribute('src', dataGif)).to.equal(true); + }); + + it('allows overriding urlAttributes', () => { + const security = createSecurity({ + urlAttributes: ['href'], // Only check href, not src + }); + + expect(security.shouldAllowAttribute('href', 'javascript:alert(1)')).to.equal(false); + expect(security.shouldAllowAttribute('src', 'javascript:alert(1)')).to.equal(true); // Not a URL attribute anymore + }); + }); + + describe('allowIncludePath', () => { + it('allows all same-origin paths', () => { + const security = createSecurity(); + + expect(security.allowIncludePath('/blocks/card/card.html')).to.equal(true); + expect(security.allowIncludePath('/scripts/file.js')).to.equal(true); + expect(security.allowIncludePath('/any/path.html')).to.equal(true); + }); + + it('handles empty or null paths', () => { + const security = createSecurity(); + + expect(security.allowIncludePath('')).to.equal(true); + expect(security.allowIncludePath(null)).to.equal(true); + }); + + it('blocks cross-origin URLs', () => { + const security = createSecurity(); + + expect(security.allowIncludePath('https://evil.com/blocks/card.html')).to.equal(false); + expect(security.allowIncludePath('http://evil.com/blocks/card.html')).to.equal(false); + expect(security.allowIncludePath('//evil.com/blocks/card.html')).to.equal(false); + }); + + it('allows same-origin full URLs', () => { + const security = createSecurity(); + + const sameOriginUrl = `${window.location.origin}/any/path/card.html`; + expect(security.allowIncludePath(sameOriginUrl)).to.equal(true); + }); + }); +}); diff --git a/test/templates/resolveTemplate.test.js b/test/templates/resolveTemplate.test.js index 941c36f..2c8f5f9 100644 --- a/test/templates/resolveTemplate.test.js +++ b/test/templates/resolveTemplate.test.js @@ -81,4 +81,21 @@ describe('resolveTemplates', () => { expect(e.message).to.be.a('string').and.matches(/^Failed to find template/); } }); + + it('throws an error if security blocks the template path', async () => { + try { + await resolveTemplate({ + template: { + path: '/blocked/path/template.html', + }, + security: { + shouldAllowAttribute: () => true, + allowIncludePath: () => false, + }, + }); + expect.fail('exception not thrown'); + } catch (e) { + expect(e.message).to.be.a('string').and.matches(/^Template fetch blocked by security policy/); + } + }); });