Skip to content

Commit 9da7914

Browse files
committed
feat: add nonce prop for CSP-compliant user-select style injection
The user-select hack injects a <style> element into the document head on drag start. Under a strict Content Security Policy (style-src without 'unsafe-inline') the browser blocks it and logs a violation. This was raised in #791 and the follow-up discussion (comment 4743333644), where the only workaround offered required pre-injecting the element server-side — awkward for client-only apps. Add an optional nonce prop on DraggableCore (inherited by Draggable and flowed through to addUserSelectStyles). When set, it's applied to the injected element via setAttribute('nonce', ...). When omitted, fall back to webpack's __webpack_nonce__ global if defined, read defensively behind a typeof guard so non-webpack bundlers don't throw. The nonce is applied only when the element is first created, preserving the existing skip-if-present behavior. Also documents enableUserSelectHack={false} as the no-prop opt-out. Closes #791
1 parent 721287a commit 9da7914

5 files changed

Lines changed: 126 additions & 2 deletions

File tree

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ A simple component for making elements draggable.
2929
- [DraggableCore](#draggablecore)
3030
- [Using nodeRef](#using-noderef)
3131
- [Controlled vs. Uncontrolled](#controlled-vs-uncontrolled)
32+
- [Content Security Policy](#content-security-policy)
3233
- [Contributing](#contributing)
3334

3435
## Installation
@@ -117,6 +118,7 @@ type DraggableData = {
117118
| `grid` | `[number, number]` | - | Snap to grid `[x, y]` |
118119
| `handle` | `string` | - | CSS selector for the drag handle |
119120
| `nodeRef` | `React.RefObject` | - | Ref to the DOM element. Required for React Strict Mode |
121+
| `nonce` | `string` | - | CSP nonce for the injected user-select `<style>` element (see [Content Security Policy](#content-security-policy)) |
120122
| `offsetParent` | `HTMLElement` | - | Custom offsetParent for drag calculations |
121123
| `onDrag` | `DraggableEventHandler` | - | Called while dragging |
122124
| `onMouseDown` | `(e: MouseEvent) => void` | - | Called on mouse down |
@@ -215,6 +217,42 @@ function ControlledDraggable() {
215217
}
216218
```
217219

220+
## Content Security Policy
221+
222+
To prevent text from being highlighted while dragging, react-draggable injects a
223+
small `<style>` element into the document `<head>` the first time a drag starts
224+
(the `enableUserSelectHack`, on by default). Under a strict Content Security
225+
Policy that omits `'unsafe-inline'` from `style-src`, the browser blocks that
226+
element and logs a CSP violation.
227+
228+
You have three ways to handle this:
229+
230+
1. **Pass a `nonce`.** Provide the same nonce your CSP header advertises and it's
231+
applied to the injected element:
232+
233+
```jsx
234+
<Draggable nonce={cspNonce}>
235+
<div>Drag me</div>
236+
</Draggable>
237+
```
238+
239+
2. **Do nothing, if you use webpack.** When no `nonce` prop is given,
240+
react-draggable falls back to webpack's
241+
[`__webpack_nonce__`](https://webpack.js.org/guides/csp/) global if your build
242+
defines it — no per-component prop needed.
243+
244+
3. **Opt out of the injected style.** Set `enableUserSelectHack={false}` and add
245+
the two rules to your own (CSP-compliant) stylesheet:
246+
247+
```css
248+
.react-draggable-transparent-selection *::-moz-selection { all: inherit; }
249+
.react-draggable-transparent-selection *::selection { all: inherit; }
250+
```
251+
252+
The nonce is only read when the element is first created. The same element is
253+
shared by every `<Draggable>`/`<DraggableCore>` on the page, so set the nonce on
254+
whichever instance drags first (or, more simply, set it consistently everywhere).
255+
218256
## Contributing
219257

220258
- Fork the project

lib/DraggableCore.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export type DraggableCoreProps = DraggableCoreDefaultProps & {
5252
grid: [number, number],
5353
handle: string,
5454
nodeRef?: React.RefObject<HTMLElement | null> | null,
55+
nonce?: string,
5556
};
5657

5758
//
@@ -189,6 +190,14 @@ export default class DraggableCore extends React.Component<Partial<DraggableCore
189190
*/
190191
nodeRef: PropTypes.object,
191192

193+
/**
194+
* `nonce` is applied to the dynamically-injected <style> element used by the
195+
* user-select hack, so it isn't blocked under a strict Content Security
196+
* Policy (`style-src` without `'unsafe-inline'`). If omitted, webpack's
197+
* `__webpack_nonce__` global is used when available.
198+
*/
199+
nonce: PropTypes.string,
200+
192201
/**
193202
* Called when dragging starts.
194203
* If this function returns the boolean false, dragging will be canceled.
@@ -346,7 +355,7 @@ export default class DraggableCore extends React.Component<Partial<DraggableCore
346355

347356
// Add a style to the body to disable user-select. This prevents text from
348357
// being selected all over the page.
349-
if (this.props.enableUserSelectHack) addUserSelectStyles(ownerDocument);
358+
if (this.props.enableUserSelectHack) addUserSelectStyles(ownerDocument, this.props.nonce);
350359

351360
// Initiate dragging. Set the current x and y as offsets
352361
// so we know how much we've moved during the drag. This allows us

lib/utils/domFns.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,14 +168,28 @@ export function getTouchIdentifier(e: MouseTouchEvent): number | undefined {
168168
//
169169
// Useful for preventing blue highlights all over everything when dragging.
170170

171+
// webpack exposes the page's CSP nonce as the free variable `__webpack_nonce__`.
172+
// Read it defensively: the `typeof` guard keeps this safe under bundlers that
173+
// don't define it (a bare reference to an undeclared identifier would throw).
174+
declare const __webpack_nonce__: string | undefined;
175+
function getDefaultNonce(): string | undefined {
176+
return typeof __webpack_nonce__ !== 'undefined' ? __webpack_nonce__ : undefined;
177+
}
178+
171179
// Note we're passing `document` b/c we could be iframed
172-
export function addUserSelectStyles(doc: Document | null | undefined) {
180+
export function addUserSelectStyles(doc: Document | null | undefined, nonce?: string | null) {
173181
if (!doc) return;
174182
let styleEl = doc.getElementById('react-draggable-style-el') as HTMLStyleElement | null;
175183
if (!styleEl) {
176184
styleEl = doc.createElement('style');
177185
styleEl.type = 'text/css';
178186
styleEl.id = 'react-draggable-style-el';
187+
// Attach a CSP nonce so a strict `style-src` policy doesn't block this
188+
// injected element. Prefer the explicit prop; otherwise fall back to
189+
// webpack's `__webpack_nonce__`. Only the first call (which creates the
190+
// element) applies it; later calls reuse the existing element as before.
191+
const resolvedNonce = nonce ?? getDefaultNonce();
192+
if (resolvedNonce) styleEl.setAttribute('nonce', resolvedNonce);
179193
styleEl.innerHTML = '.react-draggable-transparent-selection *::-moz-selection {all: inherit;}\n';
180194
styleEl.innerHTML += '.react-draggable-transparent-selection *::selection {all: inherit;}\n';
181195
doc.getElementsByTagName('head')[0].appendChild(styleEl);

test/DraggableCore.test.jsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,28 @@ describe('DraggableCore', () => {
504504
});
505505
});
506506

507+
describe('nonce prop', () => {
508+
function removeStyleEl() {
509+
const el = document.getElementById('react-draggable-style-el');
510+
if (el && el.parentNode) el.parentNode.removeChild(el);
511+
}
512+
beforeEach(removeStyleEl);
513+
afterEach(removeStyleEl);
514+
515+
it('applies the nonce to the injected style element on drag start', () => {
516+
const { container } = render(
517+
<DraggableCoreWrapper nonce="test-nonce">
518+
<div />
519+
</DraggableCoreWrapper>
520+
);
521+
522+
startDrag(container.firstChild, { x: 0, y: 0 });
523+
const styleEl = document.getElementById('react-draggable-style-el');
524+
expect(styleEl).not.toBe(null);
525+
expect(styleEl.getAttribute('nonce')).toBe('test-nonce');
526+
});
527+
});
528+
507529
describe('unmount safety', () => {
508530
it('should track mounted state correctly', () => {
509531
const coreRef = React.createRef();

test/utils/domFns.extra.test.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,47 @@ describe('domFns - additional coverage', () => {
181181
expect(all.length).toBe(1);
182182
});
183183

184+
it('does not set a nonce attribute when none is provided', () => {
185+
addUserSelectStyles(document);
186+
const styleEl = document.getElementById('react-draggable-style-el');
187+
expect(styleEl.hasAttribute('nonce')).toBe(false);
188+
});
189+
190+
it('applies an explicit nonce to the injected style element', () => {
191+
addUserSelectStyles(document, 'abc123');
192+
const styleEl = document.getElementById('react-draggable-style-el');
193+
expect(styleEl.getAttribute('nonce')).toBe('abc123');
194+
});
195+
196+
it('does not retroactively set a nonce on an already-injected element', () => {
197+
addUserSelectStyles(document);
198+
addUserSelectStyles(document, 'abc123');
199+
const styleEl = document.getElementById('react-draggable-style-el');
200+
expect(styleEl.hasAttribute('nonce')).toBe(false);
201+
});
202+
203+
it('falls back to __webpack_nonce__ when no explicit nonce is passed', () => {
204+
globalThis.__webpack_nonce__ = 'from-webpack';
205+
try {
206+
addUserSelectStyles(document);
207+
const styleEl = document.getElementById('react-draggable-style-el');
208+
expect(styleEl.getAttribute('nonce')).toBe('from-webpack');
209+
} finally {
210+
delete globalThis.__webpack_nonce__;
211+
}
212+
});
213+
214+
it('prefers an explicit nonce over __webpack_nonce__', () => {
215+
globalThis.__webpack_nonce__ = 'from-webpack';
216+
try {
217+
addUserSelectStyles(document, 'explicit');
218+
const styleEl = document.getElementById('react-draggable-style-el');
219+
expect(styleEl.getAttribute('nonce')).toBe('explicit');
220+
} finally {
221+
delete globalThis.__webpack_nonce__;
222+
}
223+
});
224+
184225
it('removes the body class via requestAnimationFrame', async () => {
185226
addUserSelectStyles(document);
186227
expect(

0 commit comments

Comments
 (0)