layerpro 3.4.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +284 -49
- package/SECURITY-HARDENING.md +265 -0
- package/SECURITY.md +8 -0
- package/global.cjs +1077 -0
- package/global.d.ts +27 -0
- package/global.js +1075 -0
- package/index.cjs +627 -492
- package/index.css +596 -503
- package/index.js +625 -492
- package/llms.txt +12 -5
- package/markdown/GUIDE.md +174 -25
- package/package.json +10 -4
- package/types/layerpro.d.ts +153 -143
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# Security Hardening Guide
|
|
2
|
+
|
|
3
|
+
This document is a security reference for LayerPro maintainers and consumers.
|
|
4
|
+
It documents every threat surface in the library, the defense-in-depth controls
|
|
5
|
+
that mitigate it, and the residual risks that must be managed at the application
|
|
6
|
+
level.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. Threat Surface Summary
|
|
11
|
+
|
|
12
|
+
| Threat | Vector | Mitigation | Residual Risk |
|
|
13
|
+
|--------|--------|------------|---------------|
|
|
14
|
+
| Global override side effects | `import 'layerpro/global'` rewrites `window.alert/confirm/prompt` | Globals are opt-in (not auto-applied) in 4.0; `layerpro.installGlobals()` for programmatic opt-in; native globals untouched by default | Consumers who import `layerpro/global` accept Promise-based globals |
|
|
15
|
+
| XSS via popup body | `body: '<script>...'` or `buttons.confirm.text: '<img onerror=...>'` | `escapeHtml()` on all string bodies, button labels, names, icons | Does **not** protect against stored XSS from same-origin `source` (see §5) |
|
|
16
|
+
| SSRF via `source` prop | `source: 'https://evil.example.com/...'` | Same-origin URL check (`url.origin !== window.location.origin`) + `fetch` mode `same-origin` | Same-origin XSS (server compromise or stored content on own domain) is still possible |
|
|
17
|
+
| CSP bypass via inline `<style>` | CSS injected via `document.head.appendChild(<style>)` | Nonce auto-detection (`window.__layerproNonce`, `<style[nonce]>`, `<script[nonce]>`, `document.body.dataset.nonce`) | If no nonce is detectable, strict CSP will silently drop the stylesheet |
|
|
18
|
+
| React DOM injection | `body: <Component />` executes arbitrary component code | React adapter loaded lazily via dynamic `import('layerpro/react')` - `react-dom` is never in the core bundle | Consumer is fully responsible for the React components they pass |
|
|
19
|
+
| Prototype pollution | `setProps` iterates `Reflect.ownKeys` and reassigns descriptors | `setProps` only operates on the frozen `layerpro` object (non-enumerable, non-configurable, non-writable) | Low - `layerpro` is frozen in `index.ts:21` |
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 2. CSP (Content Security Policy) Support
|
|
24
|
+
|
|
25
|
+
LayerPro supports three strategies for strict CSP environments, in order
|
|
26
|
+
of preference:
|
|
27
|
+
|
|
28
|
+
### Strategy A - Static stylesheet import (recommended)
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
import 'layerpro/style.css'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
This serves the CSS as a static file under `style-src 'self'`. No inline
|
|
35
|
+
`<style>` element is created at all, so no nonce or hash is needed.
|
|
36
|
+
|
|
37
|
+
In `package.json`:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"exports": {
|
|
42
|
+
"./style.css": "./dist/index.css"
|
|
43
|
+
},
|
|
44
|
+
"files": ["dist/index.css", "..."]
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Strategy B - Nonce auto-detection
|
|
49
|
+
|
|
50
|
+
When the CSS-in-JS injection path is used (i.e., `import 'layerpro'` without
|
|
51
|
+
the static stylesheet), the injected `<style>` element reads the nonce from:
|
|
52
|
+
|
|
53
|
+
1. `window.__layerproNonce` - set explicitly via `layerpro.config({ nonce })`
|
|
54
|
+
2. Any existing `<style>` or `<script>` element with a `nonce` attribute
|
|
55
|
+
3. `document.body.dataset.nonce`
|
|
56
|
+
|
|
57
|
+
This is implemented in `tsup.plugin.injectCss.ts`:
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
const n = window.__layerproNonce
|
|
61
|
+
|| window.layerpro?.nonce
|
|
62
|
+
|| document.querySelector('style[nonce],script[nonce]')?.nonce
|
|
63
|
+
|| document.body?.dataset?.nonce
|
|
64
|
+
if (n) s.setAttribute('nonce', n)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Under a strict `style-src` policy with no detectable nonce, the stylesheet
|
|
68
|
+
is **silently dropped** (the browser rejects it). Popups will render unstyled.
|
|
69
|
+
|
|
70
|
+
### Strategy C - Explicit nonce configuration
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
// Call BEFORE importing LayerPro so the nonce is set at injection time
|
|
74
|
+
window.__layerproNonce = 'your-base64-nonce-here'
|
|
75
|
+
// OR
|
|
76
|
+
import 'layerpro'
|
|
77
|
+
layerpro.config({ nonce: 'your-base64-nonce-here' })
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
> **Important**: `layerpro.config({ nonce })` must be called **before** the
|
|
81
|
+
> CSS is first injected (i.e., before `import 'layerpro'`). If the module
|
|
82
|
+
> was already imported, the nonce will not be retroactively applied to the
|
|
83
|
+
> already-injected `<style>`.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 3. Global Override Security Model
|
|
88
|
+
|
|
89
|
+
### Risk (legacy 3.x pattern)
|
|
90
|
+
|
|
91
|
+
In LayerPro 3.x, `import 'layerpro'` unconditionally replaced
|
|
92
|
+
`window.alert`, `window.confirm`, `window.prompt`, and added `window.message`.
|
|
93
|
+
The overridden versions were **callback-based and asynchronous** - they did not
|
|
94
|
+
return native values:
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
import 'layerpro'
|
|
98
|
+
|
|
99
|
+
confirm('Are you sure?') // returns undefined, NOT a boolean
|
|
100
|
+
if (confirm('Proceed?')) { ... } // ALWAYS falsey - silently breaks!
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Any third-party code in your `node_modules` that relied on synchronous return
|
|
104
|
+
values would break **silently** (no error, no warning, just the falsy branch).
|
|
105
|
+
|
|
106
|
+
### 4.0 Mitigation: opt-in globals
|
|
107
|
+
|
|
108
|
+
LayerPro 4.0 **removes the auto-override**. The main entry (`import 'layerpro'`)
|
|
109
|
+
no longer touches browser globals at all. To opt in to global overrides, use:
|
|
110
|
+
|
|
111
|
+
```js
|
|
112
|
+
import 'layerpro/global' // explicitly overrides window.alert/confirm/prompt/message
|
|
113
|
+
|
|
114
|
+
const confirmed = await confirm('Are you sure?') // Promise<boolean>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Or programmatically:
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
import 'layerpro'
|
|
121
|
+
layerpro.installGlobals() // same effect, explicit call
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Recommendation
|
|
125
|
+
|
|
126
|
+
In 4.0, simply **do not import `layerpro/global`** if you need the native
|
|
127
|
+
synchronous `alert`/`confirm`/`prompt`. Use the Promise-based methods on the
|
|
128
|
+
`layerpro` object directly instead:
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
import 'layerpro'
|
|
132
|
+
|
|
133
|
+
const confirmed = await layerpro.confirm('Are you sure?')
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## 4. XSS Prevention
|
|
139
|
+
|
|
140
|
+
All user-provided string content is escaped via `escapeHtml()` (regex-based
|
|
141
|
+
replacement of `& < > " '` → HTML entities) before being inserted into the DOM:
|
|
142
|
+
|
|
143
|
+
- **Popup body (text/HTML strings)**: `core/genLayer.ts:escapeHtml()`
|
|
144
|
+
- **Button labels**: `core/genLayer.ts:escapeHtml()` (confirm/cancel text)
|
|
145
|
+
- **Popup name/icon**: `core/genLayer.ts:escapeHtml()`
|
|
146
|
+
- **`alert()`/`confirm()`/`prompt()`/`message()` bodies**: `core/message.ts:escapeHtml()`
|
|
147
|
+
|
|
148
|
+
This is **not** a sanitization library - it does not allow any HTML through.
|
|
149
|
+
Raw HTML rendering is deliberately not supported for string bodies. If you
|
|
150
|
+
need raw HTML, use the React adapter with a component that explicitly
|
|
151
|
+
renders `dangerouslySetInnerHTML`.
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## 5. `source` Prop (Remote Content Loading)
|
|
156
|
+
|
|
157
|
+
The `source` prop fetches same-origin HTML and injects it into the popup body.
|
|
158
|
+
|
|
159
|
+
### Same-origin enforcement
|
|
160
|
+
|
|
161
|
+
- `core/genLayer.ts:loadSource()` constructs a `URL` and checks
|
|
162
|
+
`url.origin !== window.location.origin`
|
|
163
|
+
- The `fetch()` call also uses `mode: 'same-origin'` as a second layer
|
|
164
|
+
- The same-origin guard is the **primary defense** against SSRF
|
|
165
|
+
|
|
166
|
+
### What is rendered
|
|
167
|
+
|
|
168
|
+
Remote HTML is injected as **raw, unescaped HTML** (as of 3.4.1 - previously
|
|
169
|
+
it was double-escaped, rendering all tags as visible text). This means:
|
|
170
|
+
|
|
171
|
+
- `<p>Hello</p>` renders as a real paragraph (not visible tags)
|
|
172
|
+
- `<script>` tags in the response are **executed** when the HTML is parsed
|
|
173
|
+
- Event handlers (`onclick="..."`) in the response are **activated**
|
|
174
|
+
|
|
175
|
+
### Consumer responsibility
|
|
176
|
+
|
|
177
|
+
The same-origin guard protects against cross-origin SSRF, but **not** against
|
|
178
|
+
same-origin XSS. If an attacker can inject malicious HTML into an endpoint
|
|
179
|
+
on your own domain (stored XSS), the `source` prop will render it.
|
|
180
|
+
|
|
181
|
+
**Always sanitize server-side content before serving it as a `source` URL.**
|
|
182
|
+
|
|
183
|
+
### Abort safety
|
|
184
|
+
|
|
185
|
+
An `AbortController` is associated with each `source` fetch and is aborted
|
|
186
|
+
when the popup closes (`layerpro.popup.abortSource(id)`), preventing
|
|
187
|
+
callbacks from targeting a destroyed layer.
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## 6. React Adapter Isolation
|
|
192
|
+
|
|
193
|
+
The React adapter (`layerpro/react`) is loaded via dynamic `import('layerpro/react')`
|
|
194
|
+
only when a React element is passed as the `body` prop:
|
|
195
|
+
|
|
196
|
+
- `react-dom` is **never** imported in the core bundle (verified by `verify-fixes.mjs`)
|
|
197
|
+
- The adapter is a separate entry point declared in `package.json` `exports`
|
|
198
|
+
- `reattach` is bundled **inside** the adapter, not as a separate dependency
|
|
199
|
+
- The adapter externalizes only `react` and `react-dom` as peer dependencies
|
|
200
|
+
|
|
201
|
+
This ensures consumers who don't use React bodies never load `react-dom`.
|
|
202
|
+
|
|
203
|
+
### Risk
|
|
204
|
+
|
|
205
|
+
The `import('layerpro/react')` bare-specifier requires a bundler that
|
|
206
|
+
understands the `exports` field in `package.json`. In plain `<script type="module">`
|
|
207
|
+
environments or old bundlers, this dynamic import will fail. The failure is
|
|
208
|
+
caught and logged:
|
|
209
|
+
|
|
210
|
+
```js
|
|
211
|
+
import('layerpro/react').then(adapter => { ... }).catch(error => {
|
|
212
|
+
console.error('LayerPro Error: React adapter failed to load.')
|
|
213
|
+
console.debug(error)
|
|
214
|
+
})
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
No error is surfaced to the user - the popup renders with an empty body.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## 7. Accessibility & Security Interaction
|
|
222
|
+
|
|
223
|
+
LayerPro renders popups with the following ARIA attributes (as of 3.4.1):
|
|
224
|
+
|
|
225
|
+
- `role="dialog"` on the popup window container
|
|
226
|
+
- `aria-modal="true"` on modal popups
|
|
227
|
+
- `aria-label` (from `props.name`) or `aria-labelledby` if a named title exists
|
|
228
|
+
- `aria-hidden="true"` on all `<body>` children except `#popup` while a modal is open
|
|
229
|
+
- `tabindex="-1"` on the popup window (focusable via script)
|
|
230
|
+
|
|
231
|
+
### Focus management
|
|
232
|
+
|
|
233
|
+
- The currently focused element is saved when a popup opens
|
|
234
|
+
- Focus is moved into the popup via `requestAnimationFrame` + `.focus()`
|
|
235
|
+
- A `Tab` key listener traps focus within the popup container (cycles to
|
|
236
|
+
first/last focusable element)
|
|
237
|
+
- On close, focus is restored to the previously active element and
|
|
238
|
+
`aria-hidden` is removed from page content
|
|
239
|
+
|
|
240
|
+
### Limitations
|
|
241
|
+
|
|
242
|
+
- This is a **lightweight** focus trap (Tab key only). It does not handle
|
|
243
|
+
screen reader virtual cursor navigation, nested modals, or `inert` polyfill.
|
|
244
|
+
- `aria-hidden` on page content is not equivalent to `inert` - focus can
|
|
245
|
+
still technically reach page elements via direct `.focus()` calls from
|
|
246
|
+
application code.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## 8. Build-time Security
|
|
251
|
+
|
|
252
|
+
- `tsup.plugin.injectCss.ts` injects CSS at build time with nonce support
|
|
253
|
+
- `tsup.plugin.copyStatic.ts` rewrites the dist `package.json` exports map
|
|
254
|
+
- `tsup.plugin.types.ts` generates `react.d.ts` and patches `index.d.ts`
|
|
255
|
+
with a `dphelper` reference (tsc strips `/// <reference>` from `.ts` files)
|
|
256
|
+
- `react.d.ts` is included in the npm `files` array so `import layerpro/react`
|
|
257
|
+
resolves types under `moduleResolution: bundler`
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## 9. Reporting
|
|
262
|
+
|
|
263
|
+
If you have a potential security vulnerability to report, please email
|
|
264
|
+
[@passariello](https://github.com/passariello) or see
|
|
265
|
+
<https://dario.passariello.ca/contact/>.
|
package/SECURITY.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
# Security
|
|
2
2
|
|
|
3
3
|
Please email [@passariello](https://github.com/passariello) or see <https://dario.passariello.ca/contact/> if you have a potential security vulnerability to report.
|
|
4
|
+
|
|
5
|
+
## Source feature (same-origin fetch)
|
|
6
|
+
|
|
7
|
+
The `source` prop fetches HTML from a same-origin URL and renders it as-is.
|
|
8
|
+
The same-origin guard prevents SSRF to other hosts, but the fetched markup is
|
|
9
|
+
NOT sanitized by LayerPro. Any same-origin endpoint used as a `source` must
|
|
10
|
+
sanitize its output server-side. Consumers needing client-side sanitization
|
|
11
|
+
should pass a React element in `body` instead.
|