@tinacms/app 2.5.4 → 2.5.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # @tinacms/app
2
2
 
3
+ ## 2.5.6
4
+
5
+ ### Patch Changes
6
+
7
+ - [#7056](https://github.com/tinacms/tinacms/pull/7056) [`c491fc5`](https://github.com/tinacms/tinacms/commit/c491fc55e612725f5d775eeb1fdf3f8ba82314fa) Thanks [@Aibono1225](https://github.com/Aibono1225)! - Harden cross-window message handling and rich-text URL sanitization.
8
+
9
+ Adds stricter origin/source checks for trusted message flows, use explicit target origins for preview iframe message, and applies URL sanitization to slatejson rich-text parsing and default rich-text link/image rendering.
10
+
11
+ - Updated dependencies [[`42760d8`](https://github.com/tinacms/tinacms/commit/42760d8f5afd201107e27e274308af37f96ba8d0), [`c491fc5`](https://github.com/tinacms/tinacms/commit/c491fc55e612725f5d775eeb1fdf3f8ba82314fa)]:
12
+ - tinacms@3.9.3
13
+ - @tinacms/mdx@2.1.7
14
+
15
+ ## 2.5.5
16
+
17
+ ### Patch Changes
18
+
19
+ - [#6681](https://github.com/tinacms/tinacms/pull/6681) [`95b7523`](https://github.com/tinacms/tinacms/commit/95b75237cb91ec3dc5dac9ce52359f9786072502) Thanks [@isaaclombardssw](https://github.com/isaaclombardssw)! - Fix click-to-edit for fields in connection query results (e.g. ruleConnection)
20
+
21
+ - Updated dependencies []:
22
+ - tinacms@3.9.2
23
+
3
24
  ## 2.5.4
4
25
 
5
26
  ### Patch Changes
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@tinacms/app",
3
- "version": "2.5.4",
3
+ "version": "2.5.6",
4
4
  "main": "src/main.tsx",
5
5
  "license": "Apache-2.0",
6
6
  "devDependencies": {
7
7
  "@types/react": "^18.3.18",
8
8
  "@types/react-dom": "^18.3.5",
9
- "typescript": "^5.7.3"
9
+ "happy-dom": "15.10.2",
10
+ "typescript": "^5.7.3",
11
+ "vite": "^5.4.14",
12
+ "vitest": "^2.1.9"
10
13
  },
11
14
  "peerDependencies": {
12
15
  "react": ">=18.3.1 <20.0.0",
@@ -26,11 +29,15 @@
26
29
  "react-router-dom": "^6.30.3",
27
30
  "typescript": "^5.7.3",
28
31
  "zod": "^3.24.2",
29
- "@tinacms/mdx": "2.1.6",
30
- "tinacms": "3.9.1"
32
+ "@tinacms/mdx": "2.1.7",
33
+ "tinacms": "3.9.3"
31
34
  },
32
35
  "repository": {
33
36
  "url": "https://github.com/tinacms/tinacms.git",
34
37
  "directory": "packages/@tinacms/app"
38
+ },
39
+ "scripts": {
40
+ "test": "vitest run",
41
+ "test-watch": "vitest"
35
42
  }
36
43
  }
@@ -3,14 +3,17 @@ import * as G from 'graphql';
3
3
  export const expandQuery = ({
4
4
  schema,
5
5
  documentNode,
6
+ includeNodeMetadata,
6
7
  }: {
7
8
  schema: G.GraphQLSchema;
8
9
  documentNode: G.DocumentNode;
10
+ includeNodeMetadata?: boolean;
9
11
  }): G.DocumentNode => {
10
12
  const documentNodeWithTypenames = addTypenameToDocument(documentNode);
11
13
  return addMetaFieldsToQuery(
12
14
  documentNodeWithTypenames,
13
- new G.TypeInfo(schema)
15
+ new G.TypeInfo(schema),
16
+ includeNodeMetadata
14
17
  );
15
18
  };
16
19
 
@@ -114,7 +117,8 @@ const addMetadataField = (
114
117
 
115
118
  const addMetaFieldsToQuery = (
116
119
  documentNode: G.DocumentNode,
117
- typeInfo: G.TypeInfo
120
+ typeInfo: G.TypeInfo,
121
+ includeNodeMetadata?: boolean
118
122
  ) => {
119
123
  const addMetaFields: G.VisitFn<G.ASTNode, G.FieldNode> = (
120
124
  node: G.FieldNode
@@ -126,7 +130,11 @@ const addMetaFieldsToQuery = (
126
130
  kind: 'SelectionSet',
127
131
  selections: [],
128
132
  }),
129
- selections: [...(node.selectionSet?.selections || []), ...metaFields],
133
+ selections: [
134
+ ...(node.selectionSet?.selections || []),
135
+ ...metaFields,
136
+ ...(includeNodeMetadata ? nodeMetadataFields : []),
137
+ ],
130
138
  },
131
139
  };
132
140
  };
@@ -218,6 +226,17 @@ const metaFields: G.SelectionNode[] =
218
226
  // @ts-ignore
219
227
  node.definitions[0].selectionSet.selections;
220
228
 
229
+ const nodeMetadataFragment = G.parse(`
230
+ query Sample {
231
+ ...on Document {
232
+ _tina_metadata
233
+ _content_source
234
+ }
235
+ }`);
236
+ const nodeMetadataFields: G.SelectionNode[] =
237
+ // @ts-ignore
238
+ nodeMetadataFragment.definitions[0].selectionSet.selections;
239
+
221
240
  export const isNodeType = (type: G.GraphQLOutputType) => {
222
241
  const namedType = G.getNamedType(type);
223
242
  if (G.isInterfaceType(namedType)) {
@@ -24,6 +24,11 @@ import { z } from 'zod';
24
24
  import { FormifyCallback, createForm, createGlobalForm } from './build-form';
25
25
  import { showErrorModal } from './errors';
26
26
  import { expandQuery, isConnectionType, isNodeType } from './expand-query';
27
+ import {
28
+ getExpectedPreviewOrigin,
29
+ isFromTrustedPreviewOrigin,
30
+ postMessageToPreview,
31
+ } from './preview-origin';
27
32
  import type {
28
33
  Payload,
29
34
  PostMessage,
@@ -200,6 +205,13 @@ export const useGraphQLReducer = (
200
205
 
201
206
  const activeField = searchParams.get('active-field');
202
207
 
208
+ // Origin of the preview document we load in the iframe. Used to validate
209
+ // inbound messages and as the explicit `targetOrigin` for outbound ones.
210
+ const expectedOrigin = React.useMemo(
211
+ () => getExpectedPreviewOrigin(url),
212
+ [url]
213
+ );
214
+
203
215
  React.useEffect(() => {
204
216
  const run = async () => {
205
217
  return Promise.all(
@@ -483,11 +495,15 @@ export const useGraphQLReducer = (
483
495
  });
484
496
  }
485
497
  }
486
- iframe.current?.contentWindow?.postMessage({
487
- type: 'updateData',
488
- id: payload.id,
489
- data: result.data,
490
- });
498
+ postMessageToPreview(
499
+ iframe.current?.contentWindow,
500
+ {
501
+ type: 'updateData',
502
+ id: payload.id,
503
+ data: result.data,
504
+ },
505
+ expectedOrigin
506
+ );
491
507
  }
492
508
  cms.dispatch({
493
509
  type: 'form-lists:add',
@@ -502,6 +518,7 @@ export const useGraphQLReducer = (
502
518
  [
503
519
  resolvedDocuments.map((doc) => doc._internalSys.path).join('.'),
504
520
  activeField,
521
+ expectedOrigin,
505
522
  ]
506
523
  );
507
524
 
@@ -527,6 +544,17 @@ export const useGraphQLReducer = (
527
544
 
528
545
  const handleMessage = React.useCallback(
529
546
  (event: MessageEvent<PostMessage>) => {
547
+ // Validate the sender before reading `event.data`: only the preview
548
+ // iframe we loaded is trusted to drive the reducer.
549
+ if (
550
+ !isFromTrustedPreviewOrigin({
551
+ event,
552
+ expectedOrigin,
553
+ peerWindow: iframe.current?.contentWindow,
554
+ })
555
+ ) {
556
+ return;
557
+ }
530
558
  if (event.data.type === 'user-select-form') {
531
559
  const incoming = event.data.formId;
532
560
  // Buffer until `forms:add` resolves it if the form isn't built yet.
@@ -538,15 +566,23 @@ export const useGraphQLReducer = (
538
566
  type: 'set-quick-editing-supported',
539
567
  value: event.data.value,
540
568
  });
541
- iframe.current?.contentWindow?.postMessage({
542
- type: 'quickEditEnabled',
543
- value: cms.state.sidebarDisplayState === 'open',
544
- });
569
+ postMessageToPreview(
570
+ iframe.current?.contentWindow,
571
+ {
572
+ type: 'quickEditEnabled',
573
+ value: cms.state.sidebarDisplayState === 'open',
574
+ },
575
+ expectedOrigin
576
+ );
545
577
  }
546
578
  if (event?.data?.type === 'isEditMode') {
547
- iframe?.current?.contentWindow?.postMessage({
548
- type: 'tina:editMode',
549
- });
579
+ postMessageToPreview(
580
+ iframe.current?.contentWindow,
581
+ {
582
+ type: 'tina:editMode',
583
+ },
584
+ expectedOrigin
585
+ );
550
586
  }
551
587
  if (event.data.type === 'field:selected') {
552
588
  const [queryId, eventFieldName] = event.data.fieldName.split('---');
@@ -597,7 +633,7 @@ export const useGraphQLReducer = (
597
633
  // });
598
634
  // }
599
635
  },
600
- [cms, JSON.stringify(results)]
636
+ [cms, JSON.stringify(results), expectedOrigin]
601
637
  );
602
638
 
603
639
  React.useEffect(() => {
@@ -627,11 +663,15 @@ export const useGraphQLReducer = (
627
663
  }, [cms.state.forms.length, pendingPrimaryId, activateFormByWireId]);
628
664
 
629
665
  React.useEffect(() => {
630
- iframe.current?.contentWindow?.postMessage({
631
- type: 'quickEditEnabled',
632
- value: cms.state.sidebarDisplayState === 'open',
633
- });
634
- }, [cms.state.sidebarDisplayState]);
666
+ postMessageToPreview(
667
+ iframe.current?.contentWindow,
668
+ {
669
+ type: 'quickEditEnabled',
670
+ value: cms.state.sidebarDisplayState === 'open',
671
+ },
672
+ expectedOrigin
673
+ );
674
+ }, [cms.state.sidebarDisplayState, expectedOrigin]);
635
675
 
636
676
  React.useEffect(() => {
637
677
  cms.dispatch({ type: 'set-edit-mode', value: 'visual' });
@@ -942,6 +982,7 @@ const expandPayload = async (
942
982
  const expandedDocumentNodeForResolver = expandQuery({
943
983
  schema: schemaForResolver,
944
984
  documentNode,
985
+ includeNodeMetadata: true,
945
986
  });
946
987
  const expandedQueryForResolver = G.print(expandedDocumentNodeForResolver);
947
988
  return { ...payload, expandedQuery, expandedData, expandedQueryForResolver };
@@ -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
+ };
package/src/lib/util.ts CHANGED
@@ -93,9 +93,10 @@ export const getDeepestMetadata = (state: Object, complexKey: string): any => {
93
93
  }
94
94
  const value = current[key];
95
95
  if (value?._tina_metadata) {
96
- // We're at a reference field, we don't want to select the
97
- // reference form, just the reference select field
98
- if (complexKey === value._tina_metadata?.prefix && metadata) {
96
+ if (value._tina_metadata.id === null) {
97
+ // Placeholder metadata from connection/list types skip it
98
+ } else if (complexKey === value._tina_metadata?.prefix && metadata) {
99
+ // Reference field — keep the parent form's metadata
99
100
  } else {
100
101
  metadata = value._tina_metadata;
101
102
  }
@@ -0,0 +1,10 @@
1
+ /// <reference types="vitest" />
2
+ import { defineConfig } from 'vite';
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ globals: true,
7
+ environment: 'happy-dom',
8
+ include: ['src/**/*.test.ts'],
9
+ },
10
+ });