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
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ A simple component for making elements draggable.
- [DraggableCore](#draggablecore)
- [Using nodeRef](#using-noderef)
- [Controlled vs. Uncontrolled](#controlled-vs-uncontrolled)
- [Content Security Policy](#content-security-policy)
- [Contributing](#contributing)

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

## Content Security Policy

To prevent text from being highlighted while dragging, react-draggable injects a
small `<style>` element into the document `<head>` the first time a drag starts
(the `enableUserSelectHack`, on by default). Under a strict Content Security
Policy that omits `'unsafe-inline'` from `style-src`, the browser blocks that
element and logs a CSP violation.

You have three ways to handle this:

1. **Pass a `nonce`.** Provide the same nonce your CSP header advertises and it's
applied to the injected element:

```jsx
<Draggable nonce={cspNonce}>
<div>Drag me</div>
</Draggable>
```

2. **Do nothing, if you use webpack.** When no `nonce` prop is given,
react-draggable falls back to webpack's
[`__webpack_nonce__`](https://webpack.js.org/guides/csp/) global if your build
defines it — no per-component prop needed.

3. **Opt out of the injected style.** Set `enableUserSelectHack={false}` and add
the two rules to your own (CSP-compliant) stylesheet:

```css
.react-draggable-transparent-selection *::-moz-selection { all: inherit; }
.react-draggable-transparent-selection *::selection { all: inherit; }
```

The nonce is only read when the element is first created. The same element is
shared by every `<Draggable>`/`<DraggableCore>` on the page, so set the nonce on
whichever instance drags first (or, more simply, set it consistently everywhere).

## Contributing

- Fork the project
Expand Down
11 changes: 10 additions & 1 deletion lib/DraggableCore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export type DraggableCoreProps = DraggableCoreDefaultProps & {
grid: [number, number],
handle: string,
nodeRef?: React.RefObject<HTMLElement | null> | null,
nonce?: string,
};

//
Expand Down Expand Up @@ -189,6 +190,14 @@ export default class DraggableCore extends React.Component<Partial<DraggableCore
*/
nodeRef: PropTypes.object,

/**
* `nonce` is applied to the dynamically-injected <style> element used by the
* user-select hack, so it isn't blocked under a strict Content Security
* Policy (`style-src` without `'unsafe-inline'`). If omitted, webpack's
* `__webpack_nonce__` global is used when available.
*/
nonce: PropTypes.string,

/**
* Called when dragging starts.
* If this function returns the boolean false, dragging will be canceled.
Expand Down Expand Up @@ -346,7 +355,7 @@ export default class DraggableCore extends React.Component<Partial<DraggableCore

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

// Initiate dragging. Set the current x and y as offsets
// so we know how much we've moved during the drag. This allows us
Expand Down
16 changes: 15 additions & 1 deletion lib/utils/domFns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,14 +168,28 @@ export function getTouchIdentifier(e: MouseTouchEvent): number | undefined {
//
// Useful for preventing blue highlights all over everything when dragging.

// webpack exposes the page's CSP nonce as the free variable `__webpack_nonce__`.
// Read it defensively: the `typeof` guard keeps this safe under bundlers that
// don't define it (a bare reference to an undeclared identifier would throw).
declare const __webpack_nonce__: string | undefined;
function getDefaultNonce(): string | undefined {
return typeof __webpack_nonce__ !== 'undefined' ? __webpack_nonce__ : undefined;
}

// Note we're passing `document` b/c we could be iframed
export function addUserSelectStyles(doc: Document | null | undefined) {
export function addUserSelectStyles(doc: Document | null | undefined, nonce?: string | null) {
if (!doc) return;
let styleEl = doc.getElementById('react-draggable-style-el') as HTMLStyleElement | null;
if (!styleEl) {
styleEl = doc.createElement('style');
styleEl.type = 'text/css';
styleEl.id = 'react-draggable-style-el';
// Attach a CSP nonce so a strict `style-src` policy doesn't block this
// injected element. Prefer the explicit prop; otherwise fall back to
// webpack's `__webpack_nonce__`. Only the first call (which creates the
// element) applies it; later calls reuse the existing element as before.
const resolvedNonce = nonce ?? getDefaultNonce();
if (resolvedNonce) styleEl.setAttribute('nonce', resolvedNonce);
styleEl.innerHTML = '.react-draggable-transparent-selection *::-moz-selection {all: inherit;}\n';
styleEl.innerHTML += '.react-draggable-transparent-selection *::selection {all: inherit;}\n';
doc.getElementsByTagName('head')[0].appendChild(styleEl);
Expand Down
22 changes: 22 additions & 0 deletions test/DraggableCore.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,28 @@ describe('DraggableCore', () => {
});
});

describe('nonce prop', () => {
function removeStyleEl() {
const el = document.getElementById('react-draggable-style-el');
if (el && el.parentNode) el.parentNode.removeChild(el);
}
beforeEach(removeStyleEl);
afterEach(removeStyleEl);

it('applies the nonce to the injected style element on drag start', () => {
const { container } = render(
<DraggableCoreWrapper nonce="test-nonce">
<div />
</DraggableCoreWrapper>
);

startDrag(container.firstChild, { x: 0, y: 0 });
const styleEl = document.getElementById('react-draggable-style-el');
expect(styleEl).not.toBe(null);
expect(styleEl.getAttribute('nonce')).toBe('test-nonce');
});
});

describe('unmount safety', () => {
it('should track mounted state correctly', () => {
const coreRef = React.createRef();
Expand Down
41 changes: 41 additions & 0 deletions test/utils/domFns.extra.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,47 @@ describe('domFns - additional coverage', () => {
expect(all.length).toBe(1);
});

it('does not set a nonce attribute when none is provided', () => {
addUserSelectStyles(document);
const styleEl = document.getElementById('react-draggable-style-el');
expect(styleEl.hasAttribute('nonce')).toBe(false);
});

it('applies an explicit nonce to the injected style element', () => {
addUserSelectStyles(document, 'abc123');
const styleEl = document.getElementById('react-draggable-style-el');
expect(styleEl.getAttribute('nonce')).toBe('abc123');
});

it('does not retroactively set a nonce on an already-injected element', () => {
addUserSelectStyles(document);
addUserSelectStyles(document, 'abc123');
const styleEl = document.getElementById('react-draggable-style-el');
expect(styleEl.hasAttribute('nonce')).toBe(false);
});

it('falls back to __webpack_nonce__ when no explicit nonce is passed', () => {
globalThis.__webpack_nonce__ = 'from-webpack';
try {
addUserSelectStyles(document);
const styleEl = document.getElementById('react-draggable-style-el');
expect(styleEl.getAttribute('nonce')).toBe('from-webpack');
} finally {
delete globalThis.__webpack_nonce__;
}
});

it('prefers an explicit nonce over __webpack_nonce__', () => {
globalThis.__webpack_nonce__ = 'from-webpack';
try {
addUserSelectStyles(document, 'explicit');
const styleEl = document.getElementById('react-draggable-style-el');
expect(styleEl.getAttribute('nonce')).toBe('explicit');
} finally {
delete globalThis.__webpack_nonce__;
}
});

it('removes the body class via requestAnimationFrame', async () => {
addUserSelectStyles(document);
expect(
Expand Down
Loading