Skip to content
Merged
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Failed loads are not cached, so a URL that errored is refetched next time. The c

Whether to run script blocks found in the SVG: `'always'`, `'once'` or `'never'`. `'once'` runs a given URL's scripts on the first injection of that URL only. Leave it at `'never'` for SVGs you don't control: see [Security](#security).

Injected SVGs don't run their `<script>` elements on their own, which is why this option exists. Script elements carrying JavaScript are removed from the injected markup whichever setting is used; only whether they are evaluated on the way out changes.
Injected SVGs don't run their `<script>` elements on their own, which is why this option exists. Script elements carrying JavaScript are removed from the injected markup whichever setting is used; only whether they are evaluated on the way out changes. The [eval scripts example](https://github.com/tanem/svg-injector/tree/master/examples/eval-scripts) shows `'once'` and `'always'` side by side over repeated injections of the same file.

#### `httpRequestWithCredentials`

Expand Down Expand Up @@ -198,6 +198,7 @@ Each name links to the example source, and the sandbox column opens it on CodeSa
| [Basic Usage](https://github.com/tanem/svg-injector/tree/master/examples/basic-usage) | [Open](https://codesandbox.io/p/devbox/github/tanem/svg-injector/tree/master/examples/basic-usage) |
| [Data URL Usage](https://github.com/tanem/svg-injector/tree/master/examples/data-url-usage) | [Open](https://codesandbox.io/p/devbox/github/tanem/svg-injector/tree/master/examples/data-url-usage) |
| [Error Handling](https://github.com/tanem/svg-injector/tree/master/examples/error-handling) | [Open](https://codesandbox.io/p/devbox/github/tanem/svg-injector/tree/master/examples/error-handling) |
| [Eval Scripts](https://github.com/tanem/svg-injector/tree/master/examples/eval-scripts) | [Open](https://codesandbox.io/p/devbox/github/tanem/svg-injector/tree/master/examples/eval-scripts) |
| [IRI Renumeration](https://github.com/tanem/svg-injector/tree/master/examples/iri-renumeration) | [Open](https://codesandbox.io/p/devbox/github/tanem/svg-injector/tree/master/examples/iri-renumeration) |
| [Sprite Usage](https://github.com/tanem/svg-injector/tree/master/examples/sprite-usage) | [Open](https://codesandbox.io/p/devbox/github/tanem/svg-injector/tree/master/examples/sprite-usage) |

Expand Down
43 changes: 43 additions & 0 deletions examples/eval-scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Eval Scripts

Two SVGs whose `<script>` elements increment a counter on the page, one injected with `evalScripts: 'once'` and the other with `evalScripts: 'always'`. A button injects both again, which is the only way to tell the two settings apart: the `'once'` counter stops at 1 and the `'always'` counter tracks the number of injections.

## What it shows

- `evalScripts` defaults to `'never'`, so neither counter would move without the option being set. Nothing here happens by default.
- `'once'` is keyed by the URL the SVG was loaded from, not by the element. Injecting the same file into a second placeholder does not run its scripts again.
- Scripts are removed from the injected markup whichever setting is used, including `'never'`. The counter of surviving `<script>` elements on the page stays at 0.
- The script in `always.svg` sits inside a `<g>` rather than at the root of the file. Before 12.0.0 that threw `NotFoundError` during removal, which left the element jammed with no callback of any kind; see [MIGRATION.md](https://github.com/tanem/svg-injector/blob/master/MIGRATION.md#v1200).
- Scripts run through `new Function`, in a closure rather than at global scope. They still have the whole document, which is how the counters get written.

## Re-injecting

Injection replaces the placeholder with the SVG, so the element passed to `SVGInjector` is gone once it succeeds. Injecting the same file again means putting a fresh placeholder in its place:

```js
const inject = (slot, src, evalScripts) => {
const placeholder = document.createElement('div')
placeholder.setAttribute('data-src', src)
slot.replaceChildren(placeholder)

SVGInjector(placeholder, { evalScripts })
}
```

The response is served from the cache from the second injection onwards, and the cached copy keeps its scripts: each injection is handed its own clone to strip.

## Security

`'always'` and `'once'` run whatever the file happens to contain. That is only safe for SVGs you control, and `beforeEach` is not a way to make it safe for the ones you don't: scripts are evaluated before `beforeEach` is called, so anything sanitised there has already run.

The [Security](https://github.com/tanem/svg-injector#security) section covers the rest, including the vectors `evalScripts` has no say over — `onload` and other event-handler attributes, `href="javascript:..."`, and `<style>` elements that reach the whole page.

## Usage

```js
import { SVGInjector } from '@tanem/svg-injector'

SVGInjector(document.getElementById('inject-me'), {
evalScripts: 'once',
})
```
107 changes: 107 additions & 0 deletions examples/eval-scripts/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>SVGInjector Eval Scripts Example</title>
<style>
body {
font-family: system-ui, sans-serif;
max-width: 640px;
margin: 2rem auto;
color: #1e293b;
}
h1 {
font-size: 1.25rem;
}
h2 {
font-size: 0.875rem;
margin-bottom: 0.25rem;
}
p {
font-size: 0.875rem;
line-height: 1.6;
color: #475569;
}
.cases {
display: flex;
gap: 1.5rem;
margin: 1.5rem 0;
}
.case {
flex: 1;
}
.case p {
margin-top: 0;
}
/* Two lines at this column width, so the placeholders below start at the
same height whichever description is wrapped. */
.case .note {
min-height: 2.8rem;
}
.count {
margin-top: 0.5rem;
font-family: ui-monospace, monospace;
font-size: 0.75rem;
}
button {
font: inherit;
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
border: 1px solid #94a3b8;
border-radius: 4px;
background: #f8fafc;
cursor: pointer;
}
#totals {
font-family: ui-monospace, monospace;
font-size: 0.75rem;
}
</style>
</head>
<body>
<h1>Eval Scripts</h1>
<p>
Two SVGs, each carrying a <code>&lt;script&gt;</code> that increments a
counter on this page. Both are injected again every time the button is
pressed. The <code>'once'</code> counter stops at 1 and the
<code>'always'</code> counter keeps climbing, which is the whole
difference between the two settings. The default,
<code>'never'</code>, would leave both at 0.
</p>
<div class="cases">
<div class="case">
<h2><code>evalScripts: 'once'</code></h2>
<p class="note">
The script sits at the root of the file, next to the circle.
</p>
<div id="once-slot"></div>
<p class="count">Script runs: <span id="once-runs">0</span></p>
</div>
<div class="case">
<h2><code>evalScripts: 'always'</code></h2>
<p class="note">
The script sits inside a <code>&lt;g&gt;</code>, alongside the square
it is grouped with.
</p>
<div id="always-slot"></div>
<p class="count">Script runs: <span id="always-runs">0</span></p>
</div>
</div>
<button id="reinject" type="button">Inject both again</button>
<p id="totals">
Injections per SVG: <span id="injection-count">0</span> &middot;
<code>&lt;script&gt;</code> elements left in the injected markup:
<span id="scripts-remaining">0</span>
</p>
<p>
The scripts are removed from the markup whichever setting is used, so
nothing above ever reaches the DOM as a <code>&lt;script&gt;</code>
element. They run through <code>new Function</code> instead, before
<code>beforeEach</code> is called and before the SVG is put on the page.
That ordering is why the
<a href="https://github.com/tanem/svg-injector#security">Security</a>
section says sanitising in <code>beforeEach</code> does not stop them.
</p>
<script type="module" src="index.ts"></script>
</body>
</html>
47 changes: 47 additions & 0 deletions examples/eval-scripts/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { SVGInjector } from '@tanem/svg-injector'

import type { EvalScripts } from '@tanem/svg-injector'

const injectionCount = document.getElementById('injection-count')!
const scriptsRemaining = document.getElementById('scripts-remaining')!

const reportScriptsRemaining = () => {
scriptsRemaining.textContent = String(
document.querySelectorAll('svg.injected-svg script').length,
)
}

// Injection replaces the placeholder with the SVG, so injecting the same file
// again means putting a fresh placeholder in the slot rather than reusing the
// element that has already left the document.
const inject = (slotId: string, src: string, evalScripts: EvalScripts) => {
const placeholder = document.createElement('div')
placeholder.setAttribute('data-src', src)
document.getElementById(slotId)!.replaceChildren(placeholder)

SVGInjector(placeholder, {
evalScripts,
afterEach(error) {
if (error) {
console.error(error)
return
}
reportScriptsRemaining()
},
})
}

const injectBoth = () => {
// `once` is keyed by the URL the SVG was loaded from, not by the element, so
// a second placeholder pointing at the same file does not run the script
// again. The response itself is served from the cache either way, and the
// cached copy keeps its scripts: each injection gets its own clone to strip.
inject('once-slot', 'once.svg', 'once')
inject('always-slot', 'always.svg', 'always')

injectionCount.textContent = String(Number(injectionCount.textContent) + 1)
}

document.getElementById('reinject')!.addEventListener('click', injectBoth)

injectBoth()
21 changes: 21 additions & 0 deletions examples/eval-scripts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "eval-scripts",
"version": "1.0.0",
"description": "SVGInjector Eval Scripts Example",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite",
"preview": "vite preview",
"start": "vite"
},
"dependencies": {
"@tanem/svg-injector": "latest"
},
"keywords": [
"@tanem/svg-injector"
],
"devDependencies": {
"vite": "^8.2.0"
}
}
19 changes: 19 additions & 0 deletions examples/eval-scripts/public/always.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions examples/eval-scripts/public/once.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions examples/eval-scripts/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"strict": true,
"module": "esnext",
"target": "es2020",
"jsx": "preserve",
"esModuleInterop": true,
"sourceMap": true,
"allowJs": true,
"lib": [
"es2020",
"dom"
],
"rootDir": ".",
"moduleResolution": "bundler"
},
"files": ["index.ts"]
}
7 changes: 7 additions & 0 deletions examples/eval-scripts/vite.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'

export default defineConfig({
// The built examples are served from a shared static server under
// <example>/dist/, so asset URLs have to be relative to the page.
base: './',
})
1 change: 1 addition & 0 deletions scripts/build-examples.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const examples = [
'basic-usage',
'data-url-usage',
'error-handling',
'eval-scripts',
'iri-renumeration',
'sprite-usage',
]
Expand Down
46 changes: 46 additions & 0 deletions test/examples.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ const examples = [
name: 'error-handling',
expectedSvgCount: 1,
},
{
name: 'eval-scripts',
expectedSvgCount: 2,
},
{
name: 'iri-renumeration',
expectedSvgCount: 3,
Expand Down Expand Up @@ -124,6 +128,48 @@ test('error-handling: failures report verbatim and render a fallback', async ({
expect(pageErrors).toEqual([])
})

// The counters are written by the SVGs' own scripts, so they only move if the
// library really evaluated them. `once` is keyed by URL and the page re-injects
// the same two files, which is what separates the two options here.
test('eval-scripts: once runs on the first injection, always runs on every one', async ({
page,
}) => {
const pageErrors: string[] = []
page.on('pageerror', (err) => pageErrors.push(err.message))

await page.goto('/eval-scripts/dist/')
await waitForInjection(page, 2)

await expect(page.locator('#injection-count')).toHaveText('1')
await expect(page.locator('#once-runs')).toHaveText('1')
await expect(page.locator('#always-runs')).toHaveText('1')

await page.locator('#reinject').click()
await waitForInjection(page, 2)
await page.locator('#reinject').click()
await waitForInjection(page, 2)

await expect(page.locator('#injection-count')).toHaveText('3')
await expect(page.locator('#once-runs')).toHaveText('1')
await expect(page.locator('#always-runs')).toHaveText('3')

expect(pageErrors).toEqual([])
})

// Scripts are stripped from the injected markup whatever `evalScripts` is set
// to, including the one nested inside a `<g>`, which used to throw on removal.
test('eval-scripts: no script elements survive injection', async ({ page }) => {
await page.goto('/eval-scripts/dist/')
await waitForInjection(page, 2)

await expect(page.locator('#scripts-remaining')).toHaveText('0')
await expect(page.locator('svg.injected-svg script')).toHaveCount(0)

// The nested script's sibling is still there, so removal took the script and
// nothing else.
await expect(page.locator('#always-slot svg g rect')).toHaveCount(1)
})

test('api-usage: beforeEach applies stroke attribute', async ({ page }) => {
await page.goto('/api-usage/dist/')
await waitForInjection(page, 2)
Expand Down
Loading