@forge/react 11.9.0-next.1 → 11.9.1-next.0
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,21 @@
|
|
|
1
1
|
# @forge/react
|
|
2
2
|
|
|
3
|
+
## 11.9.1-next.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- f57dd69: Bug fix for usePermissions hook
|
|
8
|
+
|
|
9
|
+
## 11.9.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 92c8fe8: Added useTheme hook to access the current theme and subscribe to theme updates
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- @forge/bridge@5.10.1
|
|
18
|
+
|
|
3
19
|
## 11.9.0-next.1
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
|
@@ -363,6 +363,369 @@ describe('usePermissions', () => {
|
|
|
363
363
|
});
|
|
364
364
|
});
|
|
365
365
|
});
|
|
366
|
+
describe('CSP path matching for client fetch', () => {
|
|
367
|
+
it('should allow any path when allowlist has no path', async () => {
|
|
368
|
+
const mockContext = {
|
|
369
|
+
permissions: {
|
|
370
|
+
external: {
|
|
371
|
+
fetch: {
|
|
372
|
+
client: ['https://cdn.example.com']
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
mockGetContext.mockResolvedValue(mockContext);
|
|
378
|
+
const requiredPermissions = {
|
|
379
|
+
external: {
|
|
380
|
+
fetch: {
|
|
381
|
+
client: [
|
|
382
|
+
'https://cdn.example.com',
|
|
383
|
+
'https://cdn.example.com/',
|
|
384
|
+
'https://cdn.example.com/any/path',
|
|
385
|
+
'https://cdn.example.com/file.js'
|
|
386
|
+
]
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
|
|
391
|
+
await (0, react_hooks_1.act)(async () => {
|
|
392
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
393
|
+
});
|
|
394
|
+
expect(result.current.isLoading).toBe(false);
|
|
395
|
+
expect(result.current.hasPermission).toBe(true);
|
|
396
|
+
});
|
|
397
|
+
it('should use prefix matching when allowlist has trailing slash', async () => {
|
|
398
|
+
const mockContext = {
|
|
399
|
+
permissions: {
|
|
400
|
+
external: {
|
|
401
|
+
fetch: {
|
|
402
|
+
client: ['https://cdn.example.com/api/']
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
mockGetContext.mockResolvedValue(mockContext);
|
|
408
|
+
const requiredPermissions = {
|
|
409
|
+
external: {
|
|
410
|
+
fetch: {
|
|
411
|
+
client: [
|
|
412
|
+
'https://cdn.example.com/api/',
|
|
413
|
+
'https://cdn.example.com/api/users',
|
|
414
|
+
'https://cdn.example.com/api/v1/data'
|
|
415
|
+
]
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
|
|
420
|
+
await (0, react_hooks_1.act)(async () => {
|
|
421
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
422
|
+
});
|
|
423
|
+
expect(result.current.isLoading).toBe(false);
|
|
424
|
+
expect(result.current.hasPermission).toBe(true);
|
|
425
|
+
});
|
|
426
|
+
it('should block paths outside prefix when using trailing slash', async () => {
|
|
427
|
+
const mockContext = {
|
|
428
|
+
permissions: {
|
|
429
|
+
external: {
|
|
430
|
+
fetch: {
|
|
431
|
+
client: ['https://cdn.example.com/api/']
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
mockGetContext.mockResolvedValue(mockContext);
|
|
437
|
+
const requiredPermissions = {
|
|
438
|
+
external: {
|
|
439
|
+
fetch: {
|
|
440
|
+
client: ['https://cdn.example.com/other', 'https://cdn.example.com/']
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
|
|
445
|
+
await (0, react_hooks_1.act)(async () => {
|
|
446
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
447
|
+
});
|
|
448
|
+
expect(result.current.isLoading).toBe(false);
|
|
449
|
+
expect(result.current.hasPermission).toBe(false);
|
|
450
|
+
expect(result.current.missingPermissions?.external?.fetch?.client).toEqual([
|
|
451
|
+
'https://cdn.example.com/other',
|
|
452
|
+
'https://cdn.example.com/'
|
|
453
|
+
]);
|
|
454
|
+
});
|
|
455
|
+
it('should use exact matching when allowlist has no trailing slash', async () => {
|
|
456
|
+
const mockContext = {
|
|
457
|
+
permissions: {
|
|
458
|
+
external: {
|
|
459
|
+
fetch: {
|
|
460
|
+
client: ['https://cdn.example.com/bundle.js']
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
mockGetContext.mockResolvedValue(mockContext);
|
|
466
|
+
const requiredPermissions = {
|
|
467
|
+
external: {
|
|
468
|
+
fetch: {
|
|
469
|
+
client: ['https://cdn.example.com/bundle.js']
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
|
|
474
|
+
await (0, react_hooks_1.act)(async () => {
|
|
475
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
476
|
+
});
|
|
477
|
+
expect(result.current.isLoading).toBe(false);
|
|
478
|
+
expect(result.current.hasPermission).toBe(true);
|
|
479
|
+
});
|
|
480
|
+
it('should block non-exact paths when using exact matching', async () => {
|
|
481
|
+
const mockContext = {
|
|
482
|
+
permissions: {
|
|
483
|
+
external: {
|
|
484
|
+
fetch: {
|
|
485
|
+
client: ['https://cdn.example.com/bundle.js']
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
mockGetContext.mockResolvedValue(mockContext);
|
|
491
|
+
const requiredPermissions = {
|
|
492
|
+
external: {
|
|
493
|
+
fetch: {
|
|
494
|
+
client: [
|
|
495
|
+
'https://cdn.example.com/bundle.js/extra',
|
|
496
|
+
'https://cdn.example.com/other.js',
|
|
497
|
+
'https://cdn.example.com/'
|
|
498
|
+
]
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
|
|
503
|
+
await (0, react_hooks_1.act)(async () => {
|
|
504
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
505
|
+
});
|
|
506
|
+
expect(result.current.isLoading).toBe(false);
|
|
507
|
+
expect(result.current.hasPermission).toBe(false);
|
|
508
|
+
expect(result.current.missingPermissions?.external?.fetch?.client).toEqual([
|
|
509
|
+
'https://cdn.example.com/bundle.js/extra',
|
|
510
|
+
'https://cdn.example.com/other.js',
|
|
511
|
+
'https://cdn.example.com/'
|
|
512
|
+
]);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
describe('Backend vs Client CSP differences', () => {
|
|
516
|
+
it('should allow any path for backend (hostname-only matching)', async () => {
|
|
517
|
+
const mockContext = {
|
|
518
|
+
permissions: {
|
|
519
|
+
external: {
|
|
520
|
+
fetch: {
|
|
521
|
+
backend: ['https://api.example.com/specific/path']
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
mockGetContext.mockResolvedValue(mockContext);
|
|
527
|
+
const requiredPermissions = {
|
|
528
|
+
external: {
|
|
529
|
+
fetch: {
|
|
530
|
+
backend: [
|
|
531
|
+
'https://api.example.com',
|
|
532
|
+
'https://api.example.com/different/path',
|
|
533
|
+
'https://api.example.com/specific/path'
|
|
534
|
+
]
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
|
|
539
|
+
await (0, react_hooks_1.act)(async () => {
|
|
540
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
541
|
+
});
|
|
542
|
+
expect(result.current.isLoading).toBe(false);
|
|
543
|
+
expect(result.current.hasPermission).toBe(true);
|
|
544
|
+
});
|
|
545
|
+
it('should demonstrate backend allows but client blocks different paths', async () => {
|
|
546
|
+
const mockContext = {
|
|
547
|
+
permissions: {
|
|
548
|
+
external: {
|
|
549
|
+
fetch: {
|
|
550
|
+
backend: ['https://api.example.com/api/'],
|
|
551
|
+
client: ['https://api.example.com/api/']
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
mockGetContext.mockResolvedValue(mockContext);
|
|
557
|
+
const requiredPermissions = {
|
|
558
|
+
external: {
|
|
559
|
+
fetch: {
|
|
560
|
+
backend: ['https://api.example.com/private/secret.json'],
|
|
561
|
+
client: ['https://api.example.com/private/secret.json']
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
|
|
566
|
+
await (0, react_hooks_1.act)(async () => {
|
|
567
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
568
|
+
});
|
|
569
|
+
expect(result.current.isLoading).toBe(false);
|
|
570
|
+
expect(result.current.hasPermission).toBe(false);
|
|
571
|
+
// Backend should pass (hostname-only), client should fail (CSP path check)
|
|
572
|
+
expect(result.current.missingPermissions?.external?.fetch?.backend).toBeUndefined();
|
|
573
|
+
expect(result.current.missingPermissions?.external?.fetch?.client).toEqual([
|
|
574
|
+
'https://api.example.com/private/secret.json'
|
|
575
|
+
]);
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
describe('CSP for resource types', () => {
|
|
579
|
+
it('should use CSP validation for images with paths', async () => {
|
|
580
|
+
const mockContext = {
|
|
581
|
+
permissions: {
|
|
582
|
+
external: {
|
|
583
|
+
images: ['https://cdn.example.com/public/']
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
mockGetContext.mockResolvedValue(mockContext);
|
|
588
|
+
const requiredPermissions = {
|
|
589
|
+
external: {
|
|
590
|
+
images: ['https://cdn.example.com/public/image.png', 'https://cdn.example.com/public/nested/image.jpg']
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
|
|
594
|
+
await (0, react_hooks_1.act)(async () => {
|
|
595
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
596
|
+
});
|
|
597
|
+
expect(result.current.isLoading).toBe(false);
|
|
598
|
+
expect(result.current.hasPermission).toBe(true);
|
|
599
|
+
});
|
|
600
|
+
it('should block images outside allowed directory', async () => {
|
|
601
|
+
const mockContext = {
|
|
602
|
+
permissions: {
|
|
603
|
+
external: {
|
|
604
|
+
images: ['https://cdn.example.com/public/']
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
mockGetContext.mockResolvedValue(mockContext);
|
|
609
|
+
const requiredPermissions = {
|
|
610
|
+
external: {
|
|
611
|
+
images: ['https://cdn.example.com/private/image.png', 'https://cdn.example.com/image.png']
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
|
|
615
|
+
await (0, react_hooks_1.act)(async () => {
|
|
616
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
617
|
+
});
|
|
618
|
+
expect(result.current.isLoading).toBe(false);
|
|
619
|
+
expect(result.current.hasPermission).toBe(false);
|
|
620
|
+
expect(result.current.missingPermissions?.external?.images).toEqual([
|
|
621
|
+
'https://cdn.example.com/private/image.png',
|
|
622
|
+
'https://cdn.example.com/image.png'
|
|
623
|
+
]);
|
|
624
|
+
});
|
|
625
|
+
it('should apply CSP to all resource types', async () => {
|
|
626
|
+
const mockContext = {
|
|
627
|
+
permissions: {
|
|
628
|
+
external: {
|
|
629
|
+
scripts: ['https://cdn.example.com'],
|
|
630
|
+
styles: ['https://cdn.example.com/css/'],
|
|
631
|
+
fonts: ['https://fonts.example.com/font.woff2']
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
mockGetContext.mockResolvedValue(mockContext);
|
|
636
|
+
const requiredPermissions = {
|
|
637
|
+
external: {
|
|
638
|
+
scripts: ['https://cdn.example.com/bundle.js'],
|
|
639
|
+
styles: ['https://cdn.example.com/css/main.css'],
|
|
640
|
+
fonts: ['https://fonts.example.com/font.woff2'] // Exact match
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
|
|
644
|
+
await (0, react_hooks_1.act)(async () => {
|
|
645
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
646
|
+
});
|
|
647
|
+
expect(result.current.isLoading).toBe(false);
|
|
648
|
+
expect(result.current.hasPermission).toBe(true);
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
describe('URL normalization and protocol handling', () => {
|
|
652
|
+
it('should handle URLs without protocol', async () => {
|
|
653
|
+
const mockContext = {
|
|
654
|
+
permissions: {
|
|
655
|
+
external: {
|
|
656
|
+
fetch: {
|
|
657
|
+
backend: ['api.example.com']
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
mockGetContext.mockResolvedValue(mockContext);
|
|
663
|
+
const requiredPermissions = {
|
|
664
|
+
external: {
|
|
665
|
+
fetch: {
|
|
666
|
+
backend: ['https://api.example.com', 'api.example.com']
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
|
|
671
|
+
await (0, react_hooks_1.act)(async () => {
|
|
672
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
673
|
+
});
|
|
674
|
+
expect(result.current.isLoading).toBe(false);
|
|
675
|
+
expect(result.current.hasPermission).toBe(true);
|
|
676
|
+
});
|
|
677
|
+
it('should handle CSP secure protocol upgrades (http -> https)', async () => {
|
|
678
|
+
const mockContext = {
|
|
679
|
+
permissions: {
|
|
680
|
+
external: {
|
|
681
|
+
fetch: {
|
|
682
|
+
client: ['http://example.com']
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
mockGetContext.mockResolvedValue(mockContext);
|
|
688
|
+
const requiredPermissions = {
|
|
689
|
+
external: {
|
|
690
|
+
fetch: {
|
|
691
|
+
client: ['http://example.com', 'https://example.com']
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
|
|
696
|
+
await (0, react_hooks_1.act)(async () => {
|
|
697
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
698
|
+
});
|
|
699
|
+
expect(result.current.isLoading).toBe(false);
|
|
700
|
+
expect(result.current.hasPermission).toBe(true);
|
|
701
|
+
});
|
|
702
|
+
it('should not allow protocol downgrades (https -> http)', async () => {
|
|
703
|
+
const mockContext = {
|
|
704
|
+
permissions: {
|
|
705
|
+
external: {
|
|
706
|
+
fetch: {
|
|
707
|
+
client: ['https://example.com']
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
mockGetContext.mockResolvedValue(mockContext);
|
|
713
|
+
const requiredPermissions = {
|
|
714
|
+
external: {
|
|
715
|
+
fetch: {
|
|
716
|
+
client: ['http://example.com']
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
const { result } = (0, react_hooks_1.renderHook)(() => (0, usePermissions_1.usePermissions)(requiredPermissions));
|
|
721
|
+
await (0, react_hooks_1.act)(async () => {
|
|
722
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
723
|
+
});
|
|
724
|
+
expect(result.current.isLoading).toBe(false);
|
|
725
|
+
expect(result.current.hasPermission).toBe(false);
|
|
726
|
+
expect(result.current.missingPermissions?.external?.fetch?.client).toEqual(['http://example.com']);
|
|
727
|
+
});
|
|
728
|
+
});
|
|
366
729
|
describe('Edge cases', () => {
|
|
367
730
|
it('should handle empty required permissions', async () => {
|
|
368
731
|
const mockContext = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"usePermissions.d.ts","sourceRoot":"","sources":["../../src/hooks/usePermissions.ts"],"names":[],"mappings":"
|
|
1
|
+
{"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"}
|
|
@@ -3,11 +3,24 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.usePermissions = void 0;
|
|
4
4
|
const react_1 = require("react");
|
|
5
5
|
const bridge_1 = require("@forge/bridge");
|
|
6
|
-
const
|
|
6
|
+
const egress_1 = require("@forge/egress");
|
|
7
7
|
/**
|
|
8
8
|
* https://ecosystem-platform.atlassian.net/browse/DEPLOY-1411
|
|
9
|
-
*
|
|
9
|
+
* Uses @forge/egress for URL matching (same logic as @forge/api)
|
|
10
10
|
*/
|
|
11
|
+
/**
|
|
12
|
+
* Helper function to extract URL string from external URL permissions.
|
|
13
|
+
* Matches the implementation in @forge/api for consistency.
|
|
14
|
+
*/
|
|
15
|
+
function extractUrlString(url) {
|
|
16
|
+
if (typeof url === 'string') {
|
|
17
|
+
return url;
|
|
18
|
+
}
|
|
19
|
+
if ('address' in url && url.address) {
|
|
20
|
+
return url.address;
|
|
21
|
+
}
|
|
22
|
+
return url.remote || '';
|
|
23
|
+
}
|
|
11
24
|
/**
|
|
12
25
|
* Resource types that can be loaded externally
|
|
13
26
|
*/
|
|
@@ -16,16 +29,6 @@ const RESOURCE_TYPES = ['fonts', 'styles', 'frames', 'images', 'media', 'scripts
|
|
|
16
29
|
* Fetch types for external requests
|
|
17
30
|
*/
|
|
18
31
|
const FETCH_TYPES = ['backend', 'client'];
|
|
19
|
-
/**
|
|
20
|
-
* Helper function to check if a URL matches any of the allowed patterns
|
|
21
|
-
* Uses minimatch for robust pattern matching with wildcards
|
|
22
|
-
*/
|
|
23
|
-
const matchesAllowedUrl = (url, allowedUrls) => {
|
|
24
|
-
return allowedUrls.some((allowedUrl) => {
|
|
25
|
-
// Use minimatch for pattern matching
|
|
26
|
-
return (0, minimatch_1.minimatch)(url, allowedUrl);
|
|
27
|
-
});
|
|
28
|
-
};
|
|
29
32
|
/**
|
|
30
33
|
* Hook for checking permissions in Forge apps
|
|
31
34
|
*
|
|
@@ -96,29 +99,25 @@ const usePermissions = (requiredPermissions) => {
|
|
|
96
99
|
const fetchUrls = external.fetch?.[type];
|
|
97
100
|
if (!fetchUrls?.length)
|
|
98
101
|
return false;
|
|
99
|
-
// Extract
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
// If item has an address property, use that
|
|
107
|
-
if ('address' in item && item.address) {
|
|
108
|
-
return item.address;
|
|
109
|
-
}
|
|
110
|
-
// Otherwise, use the remote property (if it exists)
|
|
111
|
-
return item.remote;
|
|
112
|
-
})
|
|
113
|
-
.filter((url) => typeof url === 'string');
|
|
114
|
-
return matchesAllowedUrl(url, allowedUrls);
|
|
102
|
+
// Extract URLs and create egress filter
|
|
103
|
+
const allowList = fetchUrls.map(extractUrlString).filter((u) => u.length > 0);
|
|
104
|
+
if (allowList.length === 0)
|
|
105
|
+
return false;
|
|
106
|
+
const egressFilter = new egress_1.EgressFilteringService(allowList);
|
|
107
|
+
// Backend: hostname-only matching, Client: CSP validation (includes paths)
|
|
108
|
+
return type === 'client' ? egressFilter.isValidUrlCSP(url) : egressFilter.isValidUrl(url);
|
|
115
109
|
},
|
|
116
110
|
canLoadResource: (type, url) => {
|
|
117
111
|
const resourceUrls = external[type];
|
|
118
112
|
if (!resourceUrls?.length)
|
|
119
113
|
return false;
|
|
120
|
-
|
|
121
|
-
|
|
114
|
+
// Extract URLs and create egress filter
|
|
115
|
+
const allowList = resourceUrls.map(extractUrlString).filter((u) => u.length > 0);
|
|
116
|
+
if (allowList.length === 0)
|
|
117
|
+
return false;
|
|
118
|
+
const egressFilter = new egress_1.EgressFilteringService(allowList);
|
|
119
|
+
// All resources use CSP validation (checks protocol + hostname + paths)
|
|
120
|
+
return egressFilter.isValidUrlCSP(url);
|
|
122
121
|
},
|
|
123
122
|
getScopes: () => scopeArray,
|
|
124
123
|
getExternalPermissions: () => external,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forge/react",
|
|
3
|
-
"version": "11.9.
|
|
3
|
+
"version": "11.9.1-next.0",
|
|
4
4
|
"description": "Forge React reconciler",
|
|
5
5
|
"author": "Atlassian",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
@@ -28,12 +28,12 @@
|
|
|
28
28
|
"@atlaskit/adf-schema": "^48.0.0",
|
|
29
29
|
"@atlaskit/adf-utils": "^19.19.0",
|
|
30
30
|
"@atlaskit/forge-react-types": "^0.48.0",
|
|
31
|
-
"@forge/bridge": "^5.10.1
|
|
31
|
+
"@forge/bridge": "^5.10.1",
|
|
32
|
+
"@forge/egress": "^2.3.1",
|
|
32
33
|
"@forge/i18n": "0.0.7",
|
|
33
34
|
"@types/react": "^18.2.64",
|
|
34
35
|
"@types/react-reconciler": "^0.28.8",
|
|
35
36
|
"lodash": "^4.17.21",
|
|
36
|
-
"minimatch": "^9.0.5",
|
|
37
37
|
"react": "^18.2.0",
|
|
38
38
|
"react-hook-form": "7.65.0",
|
|
39
39
|
"react-reconciler": "^0.29.0",
|