Skip to content

Commit 5611b11

Browse files
authored
feat: add nonce prop for CSP-compliant user-select style injection (#808)
Adds an optional nonce prop on DraggableCore (inherited by Draggable, flows through to addUserSelectStyles), applied to the injected user-select <style> element so a strict style-src CSP doesn't block it. Falls back to webpack's __webpack_nonce__ when no prop is passed. CI green (lint, typecheck, test 20/22/24, coverage, browser). Closes #791.
1 parent 721287a commit 5611b11

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)