@forge/react

11.9.011.9.1-next.0
~

Modified (7 files)

Index: package/out/hooks/usePermissions.js
===================================================================
--- package/out/hooks/usePermissions.js
+++ package/out/hooks/usePermissions.js
@@ -2,32 +2,35 @@
 Object.defineProperty(exports, "__esModule", { value: true });
 exports.usePermissions = void 0;
 const react_1 = require("react");
 const bridge_1 = require("@forge/bridge");
-const minimatch_1 = require("minimatch");
+const egress_1 = require("@forge/egress");
 /**
  * https://ecosystem-platform.atlassian.net/browse/DEPLOY-1411
- * reuse logic from @forge/api
+ * Uses @forge/egress for URL matching (same logic as @forge/api)
  */
 /**
+ * Helper function to extract URL string from external URL permissions.
+ * Matches the implementation in @forge/api for consistency.
+ */
+function extractUrlString(url) {
+    if (typeof url === 'string') {
+        return url;
+    }
+    if ('address' in url && url.address) {
+        return url.address;
+    }
+    return url.remote || '';
+}
+/**
  * Resource types that can be loaded externally
  */
 const RESOURCE_TYPES = ['fonts', 'styles', 'frames', 'images', 'media', 'scripts'];
 /**
  * Fetch types for external requests
  */
 const FETCH_TYPES = ['backend', 'client'];
 /**
- * Helper function to check if a URL matches any of the allowed patterns
- * Uses minimatch for robust pattern matching with wildcards
- */
-const matchesAllowedUrl = (url, allowedUrls) => {
-    return allowedUrls.some((allowedUrl) => {
-        // Use minimatch for pattern matching
-        return (0, minimatch_1.minimatch)(url, allowedUrl);
-    });
-};
-/**
  * Hook for checking permissions in Forge apps
  *
  * @param requiredPermissions - The permissions required for the component
  * @returns Object containing permission state, loading status, and error information
@@ -95,31 +98,27 @@
             canFetchFrom: (type, url) => {
                 const fetchUrls = external.fetch?.[type];
                 if (!fetchUrls?.length)
                     return false;
-                // Extract string URLs from fetch URLs array
-                const allowedUrls = fetchUrls
-                    .map((item) => {
-                    // If item is already a string, use it directly
-                    if (typeof item === 'string') {
-                        return item;
-                    }
-                    // If item has an address property, use that
-                    if ('address' in item && item.address) {
-                        return item.address;
-                    }
-                    // Otherwise, use the remote property (if it exists)
-                    return item.remote;
-                })
-                    .filter((url) => typeof url === 'string');
-                return matchesAllowedUrl(url, allowedUrls);
+                // Extract URLs and create egress filter
+                const allowList = fetchUrls.map(extractUrlString).filter((u) => u.length > 0);
+                if (allowList.length === 0)
+                    return false;
+                const egressFilter = new egress_1.EgressFilteringService(allowList);
+                // Backend: hostname-only matching, Client: CSP validation (includes paths)
+                return type === 'client' ? egressFilter.isValidUrlCSP(url) : egressFilter.isValidUrl(url);
             },
             canLoadResource: (type, url) => {
                 const resourceUrls = external[type];
                 if (!resourceUrls?.length)
                     return false;
-                const stringUrls = resourceUrls.filter((item) => typeof item === 'string');
-                return matchesAllowedUrl(url, stringUrls);
+                // Extract URLs and create egress filter
+                const allowList = resourceUrls.map(extractUrlString).filter((u) => u.length > 0);
+                if (allowList.length === 0)
+                    return false;
+                const egressFilter = new egress_1.EgressFilteringService(allowList);
+                // All resources use CSP validation (checks protocol + hostname + paths)
+                return egressFilter.isValidUrlCSP(url);
             },
             getScopes: () => scopeArray,
             getExternalPermissions: () => external,
             hasAnyPermissions: () => scopeArray.length > 0 || Object.keys(external).length > 0
Index: package/out/hooks/__test__/usePermissions.test.js
===================================================================
--- package/out/hooks/__test__/usePermissions.test.js
+++ package/out/hooks/__test__/usePermissions.test.js
@@ -362,8 +362,371 @@
                 }
             });
         });
     });
+    describe('CSP path matching for client fetch', () => {
+        it('should allow any path when allowlist has no path', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            client: ['https://cdn.example.com']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        client: [
+                            'https://cdn.example.com',
+                            'https://cdn.example.com/',
+                            'https://cdn.example.com/any/path',
+                            'https://cdn.example.com/file.js'
+                        ]
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(true);
+        });
+        it('should use prefix matching when allowlist has trailing slash', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            client: ['https://cdn.example.com/api/']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        client: [
+                            'https://cdn.example.com/api/',
+                            'https://cdn.example.com/api/users',
+                            'https://cdn.example.com/api/v1/data'
+                        ]
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(true);
+        });
+        it('should block paths outside prefix when using trailing slash', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            client: ['https://cdn.example.com/api/']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        client: ['https://cdn.example.com/other', 'https://cdn.example.com/']
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(false);
+            expect(result.current.missingPermissions?.external?.fetch?.client).toEqual([
+                'https://cdn.example.com/other',
+                'https://cdn.example.com/'
+            ]);
+        });
+        it('should use exact matching when allowlist has no trailing slash', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            client: ['https://cdn.example.com/bundle.js']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        client: ['https://cdn.example.com/bundle.js']
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(true);
+        });
+        it('should block non-exact paths when using exact matching', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            client: ['https://cdn.example.com/bundle.js']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        client: [
+                            'https://cdn.example.com/bundle.js/extra',
+                            'https://cdn.example.com/other.js',
+                            'https://cdn.example.com/'
+                        ]
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(false);
+            expect(result.current.missingPermissions?.external?.fetch?.client).toEqual([
+                'https://cdn.example.com/bundle.js/extra',
+                'https://cdn.example.com/other.js',
+                'https://cdn.example.com/'
+            ]);
+        });
+    });
+    describe('Backend vs Client CSP differences', () => {
+        it('should allow any path for backend (hostname-only matching)', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            backend: ['https://api.example.com/specific/path']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        backend: [
+                            'https://api.example.com',
+                            'https://api.example.com/different/path',
+                            'https://api.example.com/specific/path'
+                        ]
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(true);
+        });
+        it('should demonstrate backend allows but client blocks different paths', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            backend: ['https://api.example.com/api/'],
+                            client: ['https://api.example.com/api/']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        backend: ['https://api.example.com/private/secret.json'],
+                        client: ['https://api.example.com/private/secret.json']
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(false);
+            // Backend should pass (hostname-only), client should fail (CSP path check)
+            expect(result.current.missingPermissions?.external?.fetch?.backend).toBeUndefined();
+            expect(result.current.missingPermissions?.external?.fetch?.client).toEqual([
+                'https://api.example.com/private/secret.json'
+            ]);
+        });
+    });
+    describe('CSP for resource types', () => {
+        it('should use CSP validation for images with paths', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        images: ['https://cdn.example.com/public/']
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    images: ['https://cdn.example.com/public/image.png', 'https://cdn.example.com/public/nested/image.jpg']
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(true);
+        });
+        it('should block images outside allowed directory', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        images: ['https://cdn.example.com/public/']
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    images: ['https://cdn.example.com/private/image.png', 'https://cdn.example.com/image.png']
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(false);
+            expect(result.current.missingPermissions?.external?.images).toEqual([
+                'https://cdn.example.com/private/image.png',
+                'https://cdn.example.com/image.png'
+            ]);
+        });
+        it('should apply CSP to all resource types', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        scripts: ['https://cdn.example.com'],
+                        styles: ['https://cdn.example.com/css/'],
+                        fonts: ['https://fonts.example.com/font.woff2']
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    scripts: ['https://cdn.example.com/bundle.js'],
+                    styles: ['https://cdn.example.com/css/main.css'],
+                    fonts: ['https://fonts.example.com/font.woff2'] // Exact match
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(true);
+        });
+    });
+    describe('URL normalization and protocol handling', () => {
+        it('should handle URLs without protocol', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            backend: ['api.example.com']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        backend: ['https://api.example.com', 'api.example.com']
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(true);
+        });
+        it('should handle CSP secure protocol upgrades (http -> https)', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            client: ['http://example.com']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        client: ['http://example.com', 'https://example.com']
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(true);
+        });
+        it('should not allow protocol downgrades (https -> http)', async () => {
+            const mockContext = {
+                permissions: {
+                    external: {
+                        fetch: {
+                            client: ['https://example.com']
+                        }
+                    }
+                }
+            };
+            mockGetContext.mockResolvedValue(mockContext);
+            const requiredPermissions = {
+                external: {
+                    fetch: {
+                        client: ['http://example.com']
+                    }
+                }
+            };
+            const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
+            await (0, react_hooks_1.act)(async () => {
+                await new Promise((resolve) => setTimeout(resolve, 10));
+            });
+            expect(result.current.isLoading).toBe(false);
+            expect(result.current.hasPermission).toBe(false);
+            expect(result.current.missingPermissions?.external?.fetch?.client).toEqual(['http://example.com']);
+        });
+    });
     describe('Edge cases', () => {
         it('should handle empty required permissions', async () => {
             const mockContext = {
                 permissions: {
Index: package/package.json
===================================================================
--- package/package.json
+++ package/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@forge/react",
-  "version": "11.9.0",
+  "version": "11.9.1-next.0",
   "description": "Forge React reconciler",
   "author": "Atlassian",
   "license": "SEE LICENSE IN LICENSE.txt",
   "main": "out/index.js",
@@ -28,13 +28,13 @@
     "@atlaskit/adf-schema": "^48.0.0",
     "@atlaskit/adf-utils": "^19.19.0",
     "@atlaskit/forge-react-types": "^0.48.0",
     "@forge/bridge": "^5.10.1",
+    "@forge/egress": "^2.3.1",
     "@forge/i18n": "0.0.7",
     "@types/react": "^18.2.64",
     "@types/react-reconciler": "^0.28.8",
     "lodash": "^4.17.21",
-    "minimatch": "^9.0.5",
     "react": "^18.2.0",
     "react-hook-form": "7.65.0",
     "react-reconciler": "^0.29.0",
     "react-test-renderer": "^18.2.0",
Index: package/out/hooks/usePermissions.d.ts.map
===================================================================
--- package/out/hooks/usePermissions.d.ts.map
+++ package/out/hooks/usePermissions.d.ts.map
@@ -1,1 +1,1 @@
-{"version":3,"file":"usePermissions.d.ts","sourceRoot":"","sources":["../../src/hooks/usePermissions.ts"],"names":[],"mappings":"AAIA;;;GAGG;AAEH;;GAEG;AACH,QAAA,MAAM,cAAc,sEAAuE,CAAC;AAC5F,oBAAY,YAAY,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAC;AAE3D;;GAEG;AACH,QAAA,MAAM,WAAW,gCAAiC,CAAC;AACnD,oBAAY,SAAS,GAAG,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAErD,MAAM,WAAW,WAAW;IAC1B,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,CAAC,EAAE;QACT,KAAK,CAAC,EAAE;YACN,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;YACnB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;SACnB,CAAC;QACF,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QACjB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;QAClB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;QAClB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;QAClB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QACjB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;KACpB,CAAC;IACF,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED;;GAEG;AACH,oBAAY,sBAAsB,GAAG,WAAW,CAAC;AAEjD;;GAEG;AACH,oBAAY,kBAAkB,GAAG,WAAW,CAAC;AAE7C;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,kBAAkB,GAAG,IAAI,CAAC;CACpC;AAaD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,cAAc,wBAAyB,sBAAsB;;;;;CA2JzE,CAAC"}
\ No newline at end of file
+{"version":3,"file":"usePermissions.d.ts","sourceRoot":"","sources":["../../src/hooks/usePermissions.ts"],"names":[],"mappings":"AAuBA;;GAEG;AACH,QAAA,MAAM,cAAc,sEAAuE,CAAC;AAC5F,oBAAY,YAAY,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAC;AAE3D;;GAEG;AACH,QAAA,MAAM,WAAW,gCAAiC,CAAC;AACnD,oBAAY,SAAS,GAAG,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAErD,MAAM,WAAW,WAAW;IAC1B,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,CAAC,EAAE;QACT,KAAK,CAAC,EAAE;YACN,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;YACnB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;SACnB,CAAC;QACF,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QACjB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;QAClB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;QAClB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;QAClB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QACjB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;KACpB,CAAC;IACF,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED;;GAEG;AACH,oBAAY,sBAAsB,GAAG,WAAW,CAAC;AAEjD;;GAEG;AACH,oBAAY,kBAAkB,GAAG,WAAW,CAAC;AAE7C;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,kBAAkB,GAAG,IAAI,CAAC;CACpC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,cAAc,wBAAyB,sBAAsB;;;;;CAqJzE,CAAC"}
\ No newline at end of file
Index: package/CHANGELOG.md
===================================================================
--- package/CHANGELOG.md
+++ package/CHANGELOG.md
@@ -1,6 +1,12 @@
 # @forge/react
 
+## 11.9.1-next.0
+
+### Patch Changes
+
+- f57dd69: Bug fix for usePermissions hook
+
 ## 11.9.0
 
 ### Minor Changes
Index: package/out/hooks/usePermissions.d.ts
===================================================================
--- package/out/hooks/usePermissions.d.ts
+++ package/out/hooks/usePermissions.d.ts
@@ -1,9 +1,5 @@
 /**
- * https://ecosystem-platform.atlassian.net/browse/DEPLOY-1411
- * reuse logic from @forge/api
- */
-/**
  * Resource types that can be loaded externally
  */
 declare const RESOURCE_TYPES: readonly ["fonts", "styles", "frames", "images", "media", "scripts"];
 export declare type ResourceType = (typeof RESOURCE_TYPES)[number];
File too large for inline diff