Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion dist/SmarkForm.esm.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/SmarkForm.esm.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/SmarkForm.umd.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/SmarkForm.umd.js.map

Large diffs are not rendered by default.

15 changes: 11 additions & 4 deletions docs/_advanced_concepts/error_codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,9 @@ The mixin type reference includes a URL part but `allowExternalMixins` is
`"block"` (the default). No network request was made.

**Fix:** Set `allowExternalMixins: "same-origin"` or `"allow"` on the root
SmarkForm instance to permit external template loading. See
SmarkForm instance to permit external template loading. For fine-grained
control, pass a per-origin object such as
`{ 'https://trusted.example.com': 'allow', '*': 'block' }`. See
[Security Considerations]({{ "/advanced_concepts/security_considerations" | relative_url }}#mixin-external-template-loading--allowexternalmixins).

---
Expand All @@ -369,8 +371,10 @@ SmarkForm instance to permit external template loading. See
The mixin type reference points to a cross-origin URL but `allowExternalMixins`
is `"same-origin"`.

**Fix:** Either move the template to the same origin or set
`allowExternalMixins: "allow"` if you trust the remote origin.
**Fix:** Either move the template to the same origin, set
`allowExternalMixins: "allow"` if you trust all remote origins, or use a
per-origin policy object to allow only specific trusted origins:
`{ 'https://trusted-cdn.example.com': 'allow', '*': 'block' }`.

---

Expand Down Expand Up @@ -441,7 +445,8 @@ top-level `<script>` and `allowLocalMixinScripts` is `"block"` (the default).
The mixin template was fetched from a same-origin URL and contains a top-level
`<script>`, but `allowSameOriginMixinScripts` is `"block"` (the default).

**Fix:** Set `allowSameOriginMixinScripts: "allow"` (or `"noscript"`).
**Fix:** Set `allowSameOriginMixinScripts: "allow"` (or `"noscript"`), or pass
a per-origin object for fine-grained control.

---

Expand All @@ -452,6 +457,8 @@ The mixin template was fetched from a cross-origin URL and contains a top-level

**Fix:** Set `allowCrossOriginMixinScripts: "allow"` (or `"noscript"`).
Only use `"allow"` if you fully control and trust that external origin.
For fine-grained per-origin control, pass an object such as:
`{ 'https://trusted.example.com': 'allow', '*': 'block' }`.

---

Expand Down
48 changes: 45 additions & 3 deletions docs/_advanced_concepts/mixin_types.md
Original file line number Diff line number Diff line change
Expand Up @@ -582,15 +582,35 @@ instance (or any ancestor component via `inheritedOption`):
Controls whether SmarkForm is permitted to fetch templates from external URLs
(any mixin type that contains a URL part before the `#` fragment).

**String form** (global policy):

| Value | Behaviour |
|---|---|
| `"block"` *(default)* | Any attempt to load an external mixin template throws `MIXIN_EXTERNAL_FETCH_BLOCKED` and halts rendering. |
| `"same-origin"` | Only same-origin URLs are permitted; cross-origin URLs throw `MIXIN_CROSS_ORIGIN_FETCH_BLOCKED`. |
| `"allow"` | External templates may be loaded from any origin. |

**Object form** (per-origin policy):

Pass a plain object whose keys are origin strings (e.g. `"https://cdn.example.com"`)
and whose values are `"block"` or `"allow"`. The special key `"*"` acts as a
wildcard default for origins not explicitly listed. If neither a matching
origin nor `"*"` is found, the policy defaults to `"block"`.

```js
// Allow loading templates from the same server only
new SmarkForm(el, { allowExternalMixins: 'same-origin' });

// Allow templates from one specific CDN only; block everything else
new SmarkForm(el, {
allowExternalMixins: {
'https://trusted-cdn.example.com': 'allow',
'*': 'block',
},
});

// Allow templates from any origin using wildcard
new SmarkForm(el, { allowExternalMixins: { '*': 'allow' } });
```

#### 2. Script execution policy — `allowLocalMixinScripts`, `allowSameOriginMixinScripts`, `allowCrossOriginMixinScripts`
Expand All @@ -605,6 +625,8 @@ by where the template was loaded from:
| External, same origin | `allowSameOriginMixinScripts` |
| External, cross-origin | `allowCrossOriginMixinScripts` |

**String form** (global policy):

Each option accepts the same three values:

| Value | Behaviour |
Expand All @@ -613,6 +635,14 @@ Each option accepts the same three values:
| `"noscript"` | The template renders normally but its `<script>` is silently discarded. Useful when you trust the template markup but not its scripts. |
| `"allow"` | Scripts are executed as normal. |

**Object form** (per-origin policy):

Any of the three script options also accepts a plain object whose keys are
origin strings and whose values are `"block"`, `"noscript"`, or `"allow"`.
The special key `"*"` provides a wildcard fallback. This is most useful for
`allowCrossOriginMixinScripts` when templates from multiple third-party origins
need different levels of trust.

```js
// Allow local mixin scripts only
new SmarkForm(el, { allowLocalMixinScripts: 'allow' });
Expand All @@ -622,12 +652,24 @@ new SmarkForm(el, {
allowExternalMixins: 'same-origin',
allowSameOriginMixinScripts: 'allow',
});

// Fine-grained cross-origin script policy: trust one CDN, silence another,
// block everything else
new SmarkForm(el, {
allowExternalMixins: 'allow',
allowCrossOriginMixinScripts: {
'https://trusted-cdn.example.com': 'allow',
'https://partial-trust.example.com': 'noscript',
'*': 'block',
},
});
```

{: .warning }
> Setting `allowCrossOriginMixinScripts` to `"allow"` grants full script
> execution for templates loaded from third-party origins. Only use this when
> you fully control and trust those external sources.
> Setting `allowCrossOriginMixinScripts` to `"allow"` (or to a per-origin
> object whose `"*"` wildcard is `"allow"`) grants full script execution for
> templates loaded from third-party origins. Only use this when you fully
> control and trust those external sources.


## Examples
Expand Down
72 changes: 62 additions & 10 deletions src/lib/mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,45 @@ function isCrossOrigin(absoluteUrl) { //{{{
}
}; //}}}

function getUrlOrigin(absoluteUrl) { //{{{
// Returns the origin string for absoluteUrl, or '' on parse failure.
try {
return new URL(absoluteUrl).origin;
} catch (_) {
return '';
}
}; //}}}

// ----------------------------------------------------------------------------
// Internal: resolvePolicy(option, origin, fallback)
// Resolves a mixin security option that may be either a plain string policy
// or a per-origin object map.
//
// When `option` is a string, it is returned as-is.
// When `option` is an object, the `origin` key is looked up first; if absent,
// the wildcard '*' key is used; if that is also absent, `fallback` is returned.
// This enables fine-grained per-origin trust policies such as:
//
// allowExternalMixins: {
// 'https://trusted-cdn.example.com': 'allow',
// '*': 'block',
// }
//
// allowCrossOriginMixinScripts: {
// 'https://trusted.example.com': 'allow',
// 'https://untrusted.example.com': 'noscript',
// '*': 'block',
// }
// ----------------------------------------------------------------------------
function resolvePolicy(option, origin, fallback) { //{{{
if (typeof option === 'string') return option;
if (option !== null && typeof option === 'object') {
if (Object.prototype.hasOwnProperty.call(option, origin)) return option[origin];
if (Object.prototype.hasOwnProperty.call(option, '*')) return option['*'];
}
return fallback;
}; //}}}

// ----------------------------------------------------------------------------
// Public: expandMixin(node, options, component)
// Expands a mixin placeholder node into its template clone.
Expand Down Expand Up @@ -197,26 +236,32 @@ export async function expandMixin(node, options, component) { //{{{
// request. The policy is read exclusively from the root SmarkForm
// instance to prevent a malicious external template from escalating
// its own privileges by setting the option in its data-smark.
const extPolicy = component.root.options['allowExternalMixins'] ?? 'block';
// allowExternalMixins may be a string ('block'|'same-origin'|'allow')
// or a per-origin object map — see resolvePolicy().
const extPolicyRaw = component.root.options['allowExternalMixins'] ?? 'block';
const fetchOrigin = getUrlOrigin(absoluteUrl);
const extPolicy = resolvePolicy(extPolicyRaw, fetchOrigin, 'block');
if (extPolicy === 'block') {
throw component.renderError(
'MIXIN_EXTERNAL_FETCH_BLOCKED'
, `Mixin type "${typeRef}" references an external URL but`
+ ' allowExternalMixins is "block" (the default).'
+ ' Set allowExternalMixins to "same-origin" or "allow" on'
+ ' the root SmarkForm instance to permit external mixin loading.'
+ ' Set allowExternalMixins to "same-origin", "allow", or a'
+ ' per-origin policy object on the root SmarkForm instance'
+ ' to permit external mixin loading.'
);
} else if (extPolicy === 'same-origin' && isCrossOrigin(absoluteUrl)) {
throw component.renderError(
'MIXIN_CROSS_ORIGIN_FETCH_BLOCKED'
, `Mixin type "${typeRef}" references a cross-origin URL`
+ ` (${new URL(absoluteUrl).origin}) but allowExternalMixins`
+ ' is "same-origin". Set allowExternalMixins to "allow" to'
+ ` (${fetchOrigin}) but allowExternalMixins`
+ ' is "same-origin". Set allowExternalMixins to "allow" or'
+ ' add the origin to the per-origin policy object to'
+ ' permit cross-origin mixin loading.'
);
}
// extPolicy === 'allow', or 'same-origin' with a same-origin URL:
// proceed with the fetch.
// extPolicy === 'allow', or 'same-origin' with a same-origin URL,
// or per-origin policy resolved to 'allow': proceed with the fetch.

if (! docCache.has(absoluteUrl)) {
docCache.set(
Expand Down Expand Up @@ -345,6 +390,10 @@ export async function expandMixin(node, options, component) { //{{{

// Apply mixin script execution policy based on template origin class.
// Scripts are blocked by default for all origin classes.
// Each policy option may be a string ('block'|'noscript'|'allow') or a
// per-origin object map — see resolvePolicy(). Per-origin maps are most
// useful for allowCrossOriginMixinScripts when templates from multiple
// different third-party origins need different trust levels.
if (scripts.length > 0) {
const isLocal = ! urlPart;
const isCross = ! isLocal && isCrossOrigin(absoluteUrl);
Expand All @@ -362,14 +411,17 @@ export async function expandMixin(node, options, component) { //{{{
}
// Read policy exclusively from the root to prevent privilege escalation
// from within mixin templates (see allowExternalMixins comment above).
const policy = component.root.options[policyOptionName] ?? 'block';
const policyRaw = component.root.options[policyOptionName] ?? 'block';
const scriptOrigin = getUrlOrigin(absoluteUrl);
const policy = resolvePolicy(policyRaw, scriptOrigin, 'block');
if (policy === 'block') {
throw component.renderError(
errorCode
, `Mixin template "${mixinKey}" contains a <script> and`
+ ` ${policyOptionName} is "block" (the default).`
+ ` Set ${policyOptionName} to "noscript" or "allow" on the`
+ ' root SmarkForm instance to change this behaviour.'
+ ` Set ${policyOptionName} to "noscript", "allow", or a`
+ ' per-origin policy object on the root SmarkForm instance'
+ ' to change this behaviour.'
);
} else if (policy === 'noscript') {
scripts = []; // Silently discard.
Expand Down
28 changes: 24 additions & 4 deletions test/doc/WRITING_TESTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,30 @@ Supported option keys (all optional):

| Option | Allowed values | Default | Purpose |
|---|---|---|---|
| `allowLocalMixinScripts` | `"block"` / `"noscript"` / `"allow"` | `"block"` | Allow `<script>` in local (`#id`) mixin templates |
| `allowSameOriginMixinScripts` | `"block"` / `"noscript"` / `"allow"` | `"block"` | Allow `<script>` in same-origin external mixin templates |
| `allowCrossOriginMixinScripts` | `"block"` / `"noscript"` / `"allow"` | `"block"` | Allow `<script>` in cross-origin external mixin templates |
| `allowExternalMixins` | `"block"` / `"same-origin"` / `"allow"` | `"block"` | Allow fetching mixin templates from external URLs |
| `allowLocalMixinScripts` | `"block"` / `"noscript"` / `"allow"` / per-origin object | `"block"` | Allow `<script>` in local (`#id`) mixin templates |
| `allowSameOriginMixinScripts` | `"block"` / `"noscript"` / `"allow"` / per-origin object | `"block"` | Allow `<script>` in same-origin external mixin templates |
| `allowCrossOriginMixinScripts` | `"block"` / `"noscript"` / `"allow"` / per-origin object | `"block"` | Allow `<script>` in cross-origin external mixin templates |
| `allowExternalMixins` | `"block"` / `"same-origin"` / `"allow"` / per-origin object | `"block"` | Allow fetching mixin templates from external URLs |

**Per-origin object form** (supported by all four options): instead of a single
string, any option may be a plain object whose keys are origin strings
(e.g. `"https://cdn.example.com"`) and whose values are the allowed string
policy values for that option. The special `"*"` key is a wildcard fallback.
When neither a matching origin key nor `"*"` is present the option defaults to
`"block"`. Example:

```json
{
"allowExternalMixins": {
"https://trusted-cdn.example.com": "allow",
"*": "block"
},
"allowCrossOriginMixinScripts": {
"https://trusted-cdn.example.com": "allow",
"*": "noscript"
}
}
```

The `smarkformOptions` parameter also updates the `default_jsHead` that the docs template generates for
the iframe preview, so the interactive preview in the documentation also works correctly.
Expand Down
Loading