Skip to content

Commit 8933097

Browse files
feat: add CSP nonce support (#395)
Add cspNonce for usePlaidLink and PlaidEmbeddedLink so integrators with nonce-based CSPs can pass the per-response nonce to the Plaid script tag.
1 parent 137f379 commit 8933097

7 files changed

Lines changed: 91 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Add `cspNonce` to support nonce-based Content Security Policies on `usePlaidLink` and `PlaidEmbeddedLink`.
6+
37
## 4.1.1
48

59
- Fix build issue from version 4.1.0

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,41 @@ the various Link options and the
8585
| `onEvent` | `(eventName: PlaidLinkStableEvent \| string, metadata: PlaidLinkOnEventMetadata) => void` |
8686
| `onLoad` | `() => void` |
8787
| `receivedRedirectUri` | `string \| null \| undefined` |
88+
| `cspNonce` | `string \| undefined` |
89+
90+
#### Content Security Policy nonce
91+
92+
If your app uses a nonce-based Content Security Policy, generate a fresh nonce
93+
per page response and pass it as `cspNonce`
94+
on `usePlaidLink` or `PlaidEmbeddedLink`. Only mount these components once
95+
`cspNonce` is known.
96+
97+
Allow that nonce in `script-src`, `style-src`, and `style-src-elem`. Link still
98+
requires `style-src-attr 'unsafe-inline'` today. You will also need `frame-src` and `connect-src` as
99+
[documented for Link Web](https://plaid.com/docs/link/web/#csp-guidance); for
100+
example:
101+
102+
```html
103+
default-src https://cdn.plaid.com/;
104+
script-src 'nonce-<PAGE_RESPONSE_NONCE>' https://cdn.plaid.com/link/v2/stable/link-initialize.js;
105+
style-src 'nonce-<PAGE_RESPONSE_NONCE>';
106+
style-src-elem 'nonce-<PAGE_RESPONSE_NONCE>';
107+
style-src-attr 'unsafe-inline';
108+
frame-src https://cdn.plaid.com/;
109+
connect-src https://production.plaid.com/;
110+
```
111+
112+
If you omit `cspNonce`, behavior is unchanged (including for embedded Link).
113+
114+
```tsx
115+
const { open, ready } = usePlaidLink({
116+
token: '<GENERATED_LINK_TOKEN>',
117+
cspNonce: '<PER_RESPONSE_NONCE>',
118+
onSuccess: (public_token, metadata) => {
119+
// send public_token to server
120+
},
121+
});
122+
```
88123

89124
#### `usePlaidLink` return value
90125

src/PlaidEmbeddedLink.test.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,31 @@ describe('PlaidEmbeddedLink', () => {
5050
rerender(<PlaidEmbeddedLink {...newConfig} />);
5151
expect(createEmbeddedSpy).toHaveBeenCalledTimes(2);
5252
});
53+
54+
it('should load the Plaid script without a nonce when cspNonce is omitted', () => {
55+
mockedUseScript.mockClear();
56+
render(<PlaidEmbeddedLink {...config} />);
57+
58+
expect(mockedUseScript).toHaveBeenCalledWith({
59+
src: 'https://cdn.plaid.com/link/v2/stable/link-initialize.js',
60+
checkForExisting: true,
61+
});
62+
expect(createEmbeddedSpy.mock.calls[0][0]).not.toHaveProperty('cspNonce');
63+
});
64+
65+
it('should pass cspNonce to the Plaid script tag only', () => {
66+
mockedUseScript.mockClear();
67+
createEmbeddedSpy.mockClear();
68+
render(<PlaidEmbeddedLink {...config} cspNonce="test-csp-nonce" />);
69+
70+
expect(mockedUseScript).toHaveBeenCalledWith({
71+
src: 'https://cdn.plaid.com/link/v2/stable/link-initialize.js',
72+
checkForExisting: true,
73+
nonce: 'test-csp-nonce',
74+
});
75+
expect(createEmbeddedSpy).toHaveBeenCalledWith(
76+
expect.not.objectContaining({ cspNonce: 'test-csp-nonce' }),
77+
expect.any(HTMLElement)
78+
);
79+
});
5380
});

src/PlaidEmbeddedLink.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const PlaidEmbeddedLink = (props: PlaidEmbeddedLinkPropTypes) => {
3535
const [loading, error] = useScript({
3636
src: PLAID_LINK_STABLE_URL,
3737
checkForExisting: true,
38+
...(props.cspNonce ? { nonce: props.cspNonce } : {}),
3839
});
3940

4041
const embeddedLinkTarget = useRef<HTMLDivElement | null>(null);
@@ -59,7 +60,8 @@ export const PlaidEmbeddedLink = (props: PlaidEmbeddedLinkPropTypes) => {
5960
// its Plaid Link instance because the embedded Link integration in link-initialize
6061
// maintains its own handler internally.
6162
const { destroy } = window.Plaid.createEmbedded(
62-
{ ...config }, embeddedLinkTarget.current as HTMLElement
63+
{ ...config },
64+
embeddedLinkTarget.current as HTMLElement
6365
);
6466

6567
// Clean up embedded Link component on unmount

src/types/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ export interface CommonPlaidLinkOptions<T> {
101101
// A callback that is called during a user's flow in Link.
102102
// See all values for eventName here https://plaid.com/docs/link/web/#link-web-onevent-eventName
103103
onEvent?: PlaidLinkOnEvent;
104+
// Sets the nonce attribute on the dynamically inserted Plaid Link script tag.
105+
// Use this to support nonce-based Content Security Policies.
106+
cspNonce?: string;
104107
}
105108

106109
/**

src/usePlaidLink.test.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,10 @@ describe('usePlaidLink', () => {
3737
};
3838

3939
beforeEach(() => {
40+
mockedUseScript.mockClear();
4041
mockedUseScript.mockImplementation(() => ScriptLoadingState.LOADED);
4142
window.Plaid = {
42-
create: ({ onLoad }) => {
43+
create: jest.fn(({ onLoad }) => {
4344
onLoad && onLoad();
4445
return {
4546
create: jest.fn(),
@@ -48,7 +49,7 @@ describe('usePlaidLink', () => {
4849
exit: jest.fn(),
4950
destroy: jest.fn(),
5051
};
51-
},
52+
}),
5253
open: jest.fn(),
5354
submit: jest.fn(),
5455
exit: jest.fn(),
@@ -67,6 +68,19 @@ describe('usePlaidLink', () => {
6768
expect(screen.getByText(ReadyState.NO_ERROR));
6869
});
6970

71+
it('should pass cspNonce to the Plaid script tag only', async () => {
72+
render(<HookComponent config={{ ...config, cspNonce: 'test-csp-nonce' }} />);
73+
74+
expect(mockedUseScript).toHaveBeenCalledWith({
75+
src: 'https://cdn.plaid.com/link/v2/stable/link-initialize.js',
76+
checkForExisting: true,
77+
nonce: 'test-csp-nonce',
78+
});
79+
expect(window.Plaid.create).toHaveBeenCalledWith(
80+
expect.not.objectContaining({ cspNonce: 'test-csp-nonce' })
81+
);
82+
});
83+
7084
it('should render with publicKey', async () => {
7185
const configWithPubKey: PlaidLinkOptions = {
7286
publicKey: 'test-public-key',

src/usePlaidLink.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const usePlaidLink = (options: PlaidLinkOptions) => {
2626
const [loading, error] = useScript({
2727
src: PLAID_LINK_STABLE_URL,
2828
checkForExisting: true,
29+
...(options.cspNonce ? { nonce: options.cspNonce } : {}),
2930
});
3031

3132
// internal state
@@ -63,9 +64,10 @@ export const usePlaidLink = (options: PlaidLinkOptions) => {
6364
plaid.exit({ force: true }, () => plaid.destroy());
6465
}
6566

67+
const { cspNonce: _cspNonce, ...linkOptions } = options;
6668
const next = createPlaid(
6769
{
68-
...options,
70+
...linkOptions,
6971
onLoad: () => {
7072
setIframeLoaded(true);
7173
options.onLoad && options.onLoad();

0 commit comments

Comments
 (0)