@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 CHANGED
@@ -1,12 +1,22 @@
1
1
  {
2
2
  "name": "@tinacms/app",
3
- "version": "2.5.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
- "typescript": "^5.7.3"
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.6",
30
- "tinacms": "3.9.2"
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
- iframe.current?.contentWindow?.postMessage({
487
- type: 'updateData',
488
- id: payload.id,
489
- data: result.data,
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
- iframe.current?.contentWindow?.postMessage({
542
- type: 'quickEditEnabled',
543
- value: cms.state.sidebarDisplayState === 'open',
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
- iframe?.current?.contentWindow?.postMessage({
548
- type: 'tina:editMode',
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
- iframe.current?.contentWindow?.postMessage({
631
- type: 'quickEditEnabled',
632
- value: cms.state.sidebarDisplayState === 'open',
633
- });
634
- }, [cms.state.sidebarDisplayState]);
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
+ };