@tinacms/app 2.5.5 → 2.5.7
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.
- package/package.json +18 -4
- package/src/lib/graphql-reducer.ts +58 -22
- package/src/lib/preview-origin.test.ts +147 -0
- package/src/lib/preview-origin.ts +61 -0
- package/CHANGELOG.md +0 -1544
package/package.json
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tinacms/app",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.7",
|
|
4
4
|
"main": "src/main.tsx",
|
|
5
|
+
"files": [
|
|
6
|
+
"src",
|
|
7
|
+
"index.html",
|
|
8
|
+
"favicon.svg",
|
|
9
|
+
"tsconfig.json",
|
|
10
|
+
"tsconfig.node.json"
|
|
11
|
+
],
|
|
5
12
|
"license": "Apache-2.0",
|
|
6
13
|
"devDependencies": {
|
|
7
14
|
"@types/react": "^18.3.18",
|
|
8
15
|
"@types/react-dom": "^18.3.5",
|
|
9
|
-
"
|
|
16
|
+
"happy-dom": "15.10.2",
|
|
17
|
+
"typescript": "^5.7.3",
|
|
18
|
+
"vite": "^5.4.14",
|
|
19
|
+
"vitest": "^2.1.9"
|
|
10
20
|
},
|
|
11
21
|
"peerDependencies": {
|
|
12
22
|
"react": ">=18.3.1 <20.0.0",
|
|
@@ -26,11 +36,15 @@
|
|
|
26
36
|
"react-router-dom": "^6.30.3",
|
|
27
37
|
"typescript": "^5.7.3",
|
|
28
38
|
"zod": "^3.24.2",
|
|
29
|
-
"@tinacms/mdx": "2.1.
|
|
30
|
-
"tinacms": "3.9.
|
|
39
|
+
"@tinacms/mdx": "2.1.8",
|
|
40
|
+
"tinacms": "3.9.4"
|
|
31
41
|
},
|
|
32
42
|
"repository": {
|
|
33
43
|
"url": "https://github.com/tinacms/tinacms.git",
|
|
34
44
|
"directory": "packages/@tinacms/app"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test-watch": "vitest"
|
|
35
49
|
}
|
|
36
50
|
}
|
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
ErrorDialog,
|
|
11
11
|
Form,
|
|
12
12
|
FormOptions,
|
|
13
|
-
GlobalFormPlugin,
|
|
14
13
|
NAMER,
|
|
15
14
|
Template,
|
|
16
15
|
TinaCMS,
|
|
@@ -24,6 +23,11 @@ import { z } from 'zod';
|
|
|
24
23
|
import { FormifyCallback, createForm, createGlobalForm } from './build-form';
|
|
25
24
|
import { showErrorModal } from './errors';
|
|
26
25
|
import { expandQuery, isConnectionType, isNodeType } from './expand-query';
|
|
26
|
+
import {
|
|
27
|
+
getExpectedPreviewOrigin,
|
|
28
|
+
isFromTrustedPreviewOrigin,
|
|
29
|
+
postMessageToPreview,
|
|
30
|
+
} from './preview-origin';
|
|
27
31
|
import type {
|
|
28
32
|
Payload,
|
|
29
33
|
PostMessage,
|
|
@@ -200,6 +204,13 @@ export const useGraphQLReducer = (
|
|
|
200
204
|
|
|
201
205
|
const activeField = searchParams.get('active-field');
|
|
202
206
|
|
|
207
|
+
// Origin of the preview document we load in the iframe. Used to validate
|
|
208
|
+
// inbound messages and as the explicit `targetOrigin` for outbound ones.
|
|
209
|
+
const expectedOrigin = React.useMemo(
|
|
210
|
+
() => getExpectedPreviewOrigin(url),
|
|
211
|
+
[url]
|
|
212
|
+
);
|
|
213
|
+
|
|
203
214
|
React.useEffect(() => {
|
|
204
215
|
const run = async () => {
|
|
205
216
|
return Promise.all(
|
|
@@ -483,11 +494,15 @@ export const useGraphQLReducer = (
|
|
|
483
494
|
});
|
|
484
495
|
}
|
|
485
496
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
497
|
+
postMessageToPreview(
|
|
498
|
+
iframe.current?.contentWindow,
|
|
499
|
+
{
|
|
500
|
+
type: 'updateData',
|
|
501
|
+
id: payload.id,
|
|
502
|
+
data: result.data,
|
|
503
|
+
},
|
|
504
|
+
expectedOrigin
|
|
505
|
+
);
|
|
491
506
|
}
|
|
492
507
|
cms.dispatch({
|
|
493
508
|
type: 'form-lists:add',
|
|
@@ -502,6 +517,7 @@ export const useGraphQLReducer = (
|
|
|
502
517
|
[
|
|
503
518
|
resolvedDocuments.map((doc) => doc._internalSys.path).join('.'),
|
|
504
519
|
activeField,
|
|
520
|
+
expectedOrigin,
|
|
505
521
|
]
|
|
506
522
|
);
|
|
507
523
|
|
|
@@ -527,6 +543,17 @@ export const useGraphQLReducer = (
|
|
|
527
543
|
|
|
528
544
|
const handleMessage = React.useCallback(
|
|
529
545
|
(event: MessageEvent<PostMessage>) => {
|
|
546
|
+
// Validate the sender before reading `event.data`: only the preview
|
|
547
|
+
// iframe we loaded is trusted to drive the reducer.
|
|
548
|
+
if (
|
|
549
|
+
!isFromTrustedPreviewOrigin({
|
|
550
|
+
event,
|
|
551
|
+
expectedOrigin,
|
|
552
|
+
peerWindow: iframe.current?.contentWindow,
|
|
553
|
+
})
|
|
554
|
+
) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
530
557
|
if (event.data.type === 'user-select-form') {
|
|
531
558
|
const incoming = event.data.formId;
|
|
532
559
|
// Buffer until `forms:add` resolves it if the form isn't built yet.
|
|
@@ -538,15 +565,23 @@ export const useGraphQLReducer = (
|
|
|
538
565
|
type: 'set-quick-editing-supported',
|
|
539
566
|
value: event.data.value,
|
|
540
567
|
});
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
568
|
+
postMessageToPreview(
|
|
569
|
+
iframe.current?.contentWindow,
|
|
570
|
+
{
|
|
571
|
+
type: 'quickEditEnabled',
|
|
572
|
+
value: cms.state.sidebarDisplayState === 'open',
|
|
573
|
+
},
|
|
574
|
+
expectedOrigin
|
|
575
|
+
);
|
|
545
576
|
}
|
|
546
577
|
if (event?.data?.type === 'isEditMode') {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
578
|
+
postMessageToPreview(
|
|
579
|
+
iframe.current?.contentWindow,
|
|
580
|
+
{
|
|
581
|
+
type: 'tina:editMode',
|
|
582
|
+
},
|
|
583
|
+
expectedOrigin
|
|
584
|
+
);
|
|
550
585
|
}
|
|
551
586
|
if (event.data.type === 'field:selected') {
|
|
552
587
|
const [queryId, eventFieldName] = event.data.fieldName.split('---');
|
|
@@ -597,7 +632,7 @@ export const useGraphQLReducer = (
|
|
|
597
632
|
// });
|
|
598
633
|
// }
|
|
599
634
|
},
|
|
600
|
-
[cms, JSON.stringify(results)]
|
|
635
|
+
[cms, JSON.stringify(results), expectedOrigin]
|
|
601
636
|
);
|
|
602
637
|
|
|
603
638
|
React.useEffect(() => {
|
|
@@ -627,11 +662,15 @@ export const useGraphQLReducer = (
|
|
|
627
662
|
}, [cms.state.forms.length, pendingPrimaryId, activateFormByWireId]);
|
|
628
663
|
|
|
629
664
|
React.useEffect(() => {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
665
|
+
postMessageToPreview(
|
|
666
|
+
iframe.current?.contentWindow,
|
|
667
|
+
{
|
|
668
|
+
type: 'quickEditEnabled',
|
|
669
|
+
value: cms.state.sidebarDisplayState === 'open',
|
|
670
|
+
},
|
|
671
|
+
expectedOrigin
|
|
672
|
+
);
|
|
673
|
+
}, [cms.state.sidebarDisplayState, expectedOrigin]);
|
|
635
674
|
|
|
636
675
|
React.useEffect(() => {
|
|
637
676
|
cms.dispatch({ type: 'set-edit-mode', value: 'visual' });
|
|
@@ -1049,9 +1088,6 @@ const buildForm = ({
|
|
|
1049
1088
|
// so without this the very first open leaves `form.queries` empty.
|
|
1050
1089
|
form.addQuery(payloadId);
|
|
1051
1090
|
if (shouldRegisterForm) {
|
|
1052
|
-
if (collection.ui?.global) {
|
|
1053
|
-
cms.plugins.add(new GlobalFormPlugin(form));
|
|
1054
|
-
}
|
|
1055
1091
|
cms.dispatch({ type: 'forms:add', value: form });
|
|
1056
1092
|
}
|
|
1057
1093
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getExpectedPreviewOrigin,
|
|
4
|
+
isFromTrustedPreviewOrigin,
|
|
5
|
+
postMessageToPreview,
|
|
6
|
+
} from './preview-origin';
|
|
7
|
+
|
|
8
|
+
const ADMIN = 'https://admin.example';
|
|
9
|
+
const PREVIEW = 'https://preview.example';
|
|
10
|
+
|
|
11
|
+
// A stand-in for the iframe's content window; we only need an identity to
|
|
12
|
+
// compare `event.source` against.
|
|
13
|
+
const makePeerWindow = () =>
|
|
14
|
+
({ postMessage: vi.fn() }) as unknown as Window & {
|
|
15
|
+
postMessage: ReturnType<typeof vi.fn>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('getExpectedPreviewOrigin', () => {
|
|
19
|
+
it('resolves a relative admin URL to the admin origin', () => {
|
|
20
|
+
expect(getExpectedPreviewOrigin('/posts/hello', ADMIN)).toBe(ADMIN);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('keeps the origin of an absolute preview URL', () => {
|
|
24
|
+
expect(getExpectedPreviewOrigin(`${PREVIEW}/posts/hello`, ADMIN)).toBe(
|
|
25
|
+
PREVIEW
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('isFromTrustedPreviewOrigin', () => {
|
|
31
|
+
it('ignores messages from an untrusted origin', () => {
|
|
32
|
+
const peerWindow = makePeerWindow();
|
|
33
|
+
const event = {
|
|
34
|
+
origin: 'https://evil.example',
|
|
35
|
+
source: peerWindow,
|
|
36
|
+
data: { type: 'open' },
|
|
37
|
+
} as unknown as MessageEvent;
|
|
38
|
+
|
|
39
|
+
expect(
|
|
40
|
+
isFromTrustedPreviewOrigin({
|
|
41
|
+
event,
|
|
42
|
+
expectedOrigin: PREVIEW,
|
|
43
|
+
peerWindow,
|
|
44
|
+
})
|
|
45
|
+
).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('accepts messages from the trusted origin and peer window', () => {
|
|
49
|
+
const peerWindow = makePeerWindow();
|
|
50
|
+
const event = {
|
|
51
|
+
origin: PREVIEW,
|
|
52
|
+
source: peerWindow,
|
|
53
|
+
data: { type: 'open' },
|
|
54
|
+
} as unknown as MessageEvent;
|
|
55
|
+
|
|
56
|
+
expect(
|
|
57
|
+
isFromTrustedPreviewOrigin({
|
|
58
|
+
event,
|
|
59
|
+
expectedOrigin: PREVIEW,
|
|
60
|
+
peerWindow,
|
|
61
|
+
})
|
|
62
|
+
).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('ignores the trusted origin from the wrong source window', () => {
|
|
66
|
+
const peerWindow = makePeerWindow();
|
|
67
|
+
const otherFrame = makePeerWindow();
|
|
68
|
+
const event = {
|
|
69
|
+
origin: PREVIEW,
|
|
70
|
+
source: otherFrame,
|
|
71
|
+
data: { type: 'open' },
|
|
72
|
+
} as unknown as MessageEvent;
|
|
73
|
+
|
|
74
|
+
expect(
|
|
75
|
+
isFromTrustedPreviewOrigin({
|
|
76
|
+
event,
|
|
77
|
+
expectedOrigin: PREVIEW,
|
|
78
|
+
peerWindow,
|
|
79
|
+
})
|
|
80
|
+
).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('does not read event.data before validating the origin', () => {
|
|
84
|
+
const peerWindow = makePeerWindow();
|
|
85
|
+
const event = {
|
|
86
|
+
origin: 'https://evil.example',
|
|
87
|
+
source: peerWindow,
|
|
88
|
+
// Reading `data` would throw — proves the guard never touches the
|
|
89
|
+
// payload when the origin is untrusted.
|
|
90
|
+
get data(): unknown {
|
|
91
|
+
throw new Error('event.data must not be read before origin validation');
|
|
92
|
+
},
|
|
93
|
+
} as unknown as MessageEvent;
|
|
94
|
+
|
|
95
|
+
expect(() =>
|
|
96
|
+
isFromTrustedPreviewOrigin({
|
|
97
|
+
event,
|
|
98
|
+
expectedOrigin: PREVIEW,
|
|
99
|
+
peerWindow,
|
|
100
|
+
})
|
|
101
|
+
).not.toThrow();
|
|
102
|
+
expect(
|
|
103
|
+
isFromTrustedPreviewOrigin({
|
|
104
|
+
event,
|
|
105
|
+
expectedOrigin: PREVIEW,
|
|
106
|
+
peerWindow,
|
|
107
|
+
})
|
|
108
|
+
).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('falls back to origin-only when no peer window handle is available', () => {
|
|
112
|
+
const event = {
|
|
113
|
+
origin: PREVIEW,
|
|
114
|
+
source: makePeerWindow(),
|
|
115
|
+
data: { type: 'open' },
|
|
116
|
+
} as unknown as MessageEvent;
|
|
117
|
+
|
|
118
|
+
expect(
|
|
119
|
+
isFromTrustedPreviewOrigin({
|
|
120
|
+
event,
|
|
121
|
+
expectedOrigin: PREVIEW,
|
|
122
|
+
peerWindow: null,
|
|
123
|
+
})
|
|
124
|
+
).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('postMessageToPreview', () => {
|
|
129
|
+
it('posts with the exact expected targetOrigin, not a wildcard', () => {
|
|
130
|
+
const peerWindow = makePeerWindow();
|
|
131
|
+
const message = { type: 'updateData', id: 'q1', data: {} };
|
|
132
|
+
|
|
133
|
+
postMessageToPreview(peerWindow, message, PREVIEW);
|
|
134
|
+
|
|
135
|
+
expect(peerWindow.postMessage).toHaveBeenCalledWith(message, PREVIEW);
|
|
136
|
+
expect(peerWindow.postMessage).not.toHaveBeenCalledWith(
|
|
137
|
+
expect.anything(),
|
|
138
|
+
'*'
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('no-ops when there is no peer window', () => {
|
|
143
|
+
expect(() =>
|
|
144
|
+
postMessageToPreview(null, { type: 'updateData' }, PREVIEW)
|
|
145
|
+
).not.toThrow();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for securing the admin <-> preview iframe postMessage channel.
|
|
3
|
+
*
|
|
4
|
+
* The trusted peer is the preview iframe loaded by the admin. Messages are only
|
|
5
|
+
* trusted when they match the expected preview origin and, when available, the
|
|
6
|
+
* iframe's exact `contentWindow`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Returns the origin expected for the preview iframe.
|
|
11
|
+
*
|
|
12
|
+
* Relative URLs resolve against `baseOrigin`; absolute URLs keep their own
|
|
13
|
+
* origin.
|
|
14
|
+
*/
|
|
15
|
+
export const getExpectedPreviewOrigin = (
|
|
16
|
+
url: string,
|
|
17
|
+
baseOrigin: string = typeof window !== 'undefined'
|
|
18
|
+
? window.location.origin
|
|
19
|
+
: ''
|
|
20
|
+
): string => {
|
|
21
|
+
try {
|
|
22
|
+
return new URL(url, baseOrigin || undefined).origin;
|
|
23
|
+
} catch {
|
|
24
|
+
return baseOrigin;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Checks whether a MessageEvent came from the trusted preview iframe.
|
|
30
|
+
*
|
|
31
|
+
* `event.data` is intentionally not read here, so callers can run this check
|
|
32
|
+
* before trusting the payload.
|
|
33
|
+
*/
|
|
34
|
+
export const isFromTrustedPreviewOrigin = ({
|
|
35
|
+
event,
|
|
36
|
+
expectedOrigin,
|
|
37
|
+
peerWindow,
|
|
38
|
+
}: {
|
|
39
|
+
event: MessageEvent;
|
|
40
|
+
expectedOrigin: string;
|
|
41
|
+
peerWindow: Window | null | undefined;
|
|
42
|
+
}): boolean => {
|
|
43
|
+
if (!expectedOrigin) return false;
|
|
44
|
+
if (event.origin !== expectedOrigin) return false;
|
|
45
|
+
if (peerWindow && event.source !== peerWindow) return false;
|
|
46
|
+
return true;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Sends a message to the preview iframe with a strict target origin.
|
|
51
|
+
*
|
|
52
|
+
* No-ops when the iframe window or origin is missing, and never uses `"*"`.
|
|
53
|
+
*/
|
|
54
|
+
export const postMessageToPreview = (
|
|
55
|
+
peerWindow: Window | null | undefined,
|
|
56
|
+
message: unknown,
|
|
57
|
+
expectedOrigin: string
|
|
58
|
+
): void => {
|
|
59
|
+
if (!peerWindow || !expectedOrigin) return;
|
|
60
|
+
peerWindow.postMessage(message, expectedOrigin);
|
|
61
|
+
};
|