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.
@@ -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.