@immediately-run/sandpack-client 2.21.0 → 2.22.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/dist/.ir-build-stamp.json +3 -3
- package/dist/clients/runtime/index.d.ts +21 -0
- package/dist/clients/runtime/index.js +158 -1
- package/dist/clients/runtime/index.mjs +158 -1
- package/dist/clients/runtime/initialization-guard.d.ts +64 -0
- package/dist/clients/runtime/types.d.ts +5 -0
- package/dist/types.d.ts +12 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sandpack-client",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"srcHash": "
|
|
5
|
-
"builtAt": "2026-
|
|
3
|
+
"version": "2.22.0",
|
|
4
|
+
"srcHash": "0ad007879d4260ed144b1aef82d68bc2332a9b291d777aed95b8320499351fac",
|
|
5
|
+
"builtAt": "2026-08-26T22:56:07.275Z"
|
|
6
6
|
}
|
|
@@ -17,8 +17,19 @@ export declare class SandpackRuntime extends SandpackClient {
|
|
|
17
17
|
unsubscribeFsWatcher?: () => void;
|
|
18
18
|
iframeProtocol: IFrameProtocol;
|
|
19
19
|
fs: SandpackFS;
|
|
20
|
+
/** Set once the teardown below has run, so a rogue document posting in a loop
|
|
21
|
+
* cannot re-run `destroy()` or re-fire the host callback. */
|
|
22
|
+
private refusedBoot;
|
|
20
23
|
/** Parent-owned Babel transpiler worker, connected to the iframe by port. */
|
|
21
24
|
private babelWorker;
|
|
25
|
+
/**
|
|
26
|
+
* Which `initialized` messages this client will honour (R3-353). Armed by
|
|
27
|
+
* {@link setLocationURLIntoIFrame} — i.e. by every navigation THIS CLIENT
|
|
28
|
+
* causes — and spent when a boot arrives. A boot the host did not cause finds
|
|
29
|
+
* nothing armed, which is the whole check. See `initialization-guard.ts` for
|
|
30
|
+
* why the parent asks "did I put you there?" rather than "where are you?".
|
|
31
|
+
*/
|
|
32
|
+
private readonly initGuard;
|
|
22
33
|
constructor(selector: string | HTMLIFrameElement, sandboxSetup: SandboxSetup, options?: ClientOptions);
|
|
23
34
|
private createBundlerURL;
|
|
24
35
|
/**
|
|
@@ -33,6 +44,16 @@ export declare class SandpackRuntime extends SandpackClient {
|
|
|
33
44
|
private serviceWorkerHandshake;
|
|
34
45
|
private handleWorkerRequest;
|
|
35
46
|
setLocationURLIntoIFrame(): void;
|
|
47
|
+
/**
|
|
48
|
+
* Spend one armed `initialized`, or refuse this boot (R3-353).
|
|
49
|
+
*
|
|
50
|
+
* Refusing is terminal for this client: the frame is blanked so the rogue
|
|
51
|
+
* document — which still holds whatever it scraped before navigating — stops
|
|
52
|
+
* executing, the client detaches, and the host is told through
|
|
53
|
+
* `onUnexpectedNavigation` so it can surface or re-create the frame. Nothing is
|
|
54
|
+
* connected and nothing is registered, so no fs port is ever minted for it.
|
|
55
|
+
*/
|
|
56
|
+
private consumeExpectedInitialization;
|
|
36
57
|
destroy(): void;
|
|
37
58
|
updateOptions(options: ClientOptions): void;
|
|
38
59
|
/**
|
|
@@ -364,6 +364,99 @@ function handleImmutableFetch(url, integrity) {
|
|
|
364
364
|
});
|
|
365
365
|
}
|
|
366
366
|
|
|
367
|
+
/**
|
|
368
|
+
* The re-register guard (R3-353; `TRUST_MODES_SPEC` §6, `UI_AS_APPS_SPEC` §G1a).
|
|
369
|
+
*
|
|
370
|
+
* ## What it defends
|
|
371
|
+
*
|
|
372
|
+
* A sandboxed frame may always navigate **itself** — no sandbox flag governs
|
|
373
|
+
* that, and `navigate-to` was dropped from CSP3, so neither half of the M3
|
|
374
|
+
* containment can prevent it. The finding that led here reads that as an egress
|
|
375
|
+
* problem (the M3 CSP travels with the birth document, so a frame that
|
|
376
|
+
* re-births itself at the policy-free baseline document gets unrestricted
|
|
377
|
+
* `connect-src` back). The bigger half is that **the host relationship travels
|
|
378
|
+
* with the document too, and the browsing context does not change**:
|
|
379
|
+
*
|
|
380
|
+
* - `iframe.contentWindow` returns the SAME `WindowProxy` across a navigation,
|
|
381
|
+
* so `IFrameProtocol`'s `evt.source !== this.frameWindow` intake check — which
|
|
382
|
+
* is correct, and is the only identity the parent has — still passes;
|
|
383
|
+
* - the client's `initialized` handler is a plain listener with no notion of how
|
|
384
|
+
* many boots it has seen, so it re-runs `fs.connectRemote()` and `register(…)`;
|
|
385
|
+
* - whatever document is in the frame now — the baseline bundler document, or a
|
|
386
|
+
* page on an origin the app chose — is therefore handed a **fresh fs port** and
|
|
387
|
+
* a fresh registration, inheriting the frame's grants.
|
|
388
|
+
*
|
|
389
|
+
* So the escalation is not "a one-shot GET carrying a small secret" (the residual
|
|
390
|
+
* `TRUST_MODES_SPEC` §6 books); it is a persistent execution context, possibly at
|
|
391
|
+
* an attacker's own origin, still attached to the host with the victim frame's
|
|
392
|
+
* authority.
|
|
393
|
+
*
|
|
394
|
+
* ## Why it is shaped as a counter
|
|
395
|
+
*
|
|
396
|
+
* The parent cannot read a cross-origin frame's `location`, so it cannot ask
|
|
397
|
+
* *"where are you?"*. It can ask *"did I put you there?"* — the same question,
|
|
398
|
+
* and one it can answer without reading anything: every legitimate boot follows a
|
|
399
|
+
* navigation the CLIENT performed (its constructor, and its `refresh` dispatch,
|
|
400
|
+
* both through `setLocationURLIntoIFrame`). Arm on navigate, spend on boot; a
|
|
401
|
+
* boot with nothing armed was not ours.
|
|
402
|
+
*
|
|
403
|
+
* A counter rather than a boolean because a rapid navigate–navigate–boot–boot
|
|
404
|
+
* sequence is legitimate and must not eat its own credit. A latch rather than
|
|
405
|
+
* decrement-below-zero because a refusal is terminal: once a rogue document has
|
|
406
|
+
* been refused, nothing it posts may re-arm anything.
|
|
407
|
+
*
|
|
408
|
+
* Kept framework-free and separate from `SandpackRuntime` so the decision can be
|
|
409
|
+
* driven directly by tests — the client itself needs a real `SandpackFS`, a Babel
|
|
410
|
+
* worker and a live iframe to construct.
|
|
411
|
+
*/
|
|
412
|
+
var InitializationGuard = /** @class */ (function () {
|
|
413
|
+
function InitializationGuard() {
|
|
414
|
+
this.expected = 0;
|
|
415
|
+
this.detached = false;
|
|
416
|
+
}
|
|
417
|
+
/** Record that the host has navigated the frame, so ONE boot is now expected. */
|
|
418
|
+
InitializationGuard.prototype.arm = function () {
|
|
419
|
+
if (this.detached)
|
|
420
|
+
return;
|
|
421
|
+
this.expected++;
|
|
422
|
+
};
|
|
423
|
+
/**
|
|
424
|
+
* Spend one armed boot. Returns `false` when this boot was not caused by the
|
|
425
|
+
* host — the caller must then refuse to connect or register anything.
|
|
426
|
+
*
|
|
427
|
+
* The first `false` **detaches** the guard permanently: every later call
|
|
428
|
+
* returns `false` too, including after an `arm()`, so a refused frame cannot be
|
|
429
|
+
* brought back by any sequence of messages.
|
|
430
|
+
*/
|
|
431
|
+
InitializationGuard.prototype.consume = function () {
|
|
432
|
+
if (this.detached)
|
|
433
|
+
return false;
|
|
434
|
+
if (this.expected > 0) {
|
|
435
|
+
this.expected--;
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
this.detached = true;
|
|
439
|
+
return false;
|
|
440
|
+
};
|
|
441
|
+
Object.defineProperty(InitializationGuard.prototype, "isDetached", {
|
|
442
|
+
/** True once an unexpected boot has been refused. Terminal. */
|
|
443
|
+
get: function () {
|
|
444
|
+
return this.detached;
|
|
445
|
+
},
|
|
446
|
+
enumerable: false,
|
|
447
|
+
configurable: true
|
|
448
|
+
});
|
|
449
|
+
Object.defineProperty(InitializationGuard.prototype, "pending", {
|
|
450
|
+
/** How many host-initiated boots are still outstanding (diagnostics/tests). */
|
|
451
|
+
get: function () {
|
|
452
|
+
return this.expected;
|
|
453
|
+
},
|
|
454
|
+
enumerable: false,
|
|
455
|
+
configurable: true
|
|
456
|
+
});
|
|
457
|
+
return InitializationGuard;
|
|
458
|
+
}());
|
|
459
|
+
|
|
367
460
|
var extensionMap = new Map();
|
|
368
461
|
var entries = Object.entries(mimeDB);
|
|
369
462
|
for (var _i = 0, entries_1 = entries; _i < entries_1.length; _i++) {
|
|
@@ -530,14 +623,25 @@ function getExtension(filepath) {
|
|
|
530
623
|
|
|
531
624
|
var _a;
|
|
532
625
|
var SUFFIX_PLACEHOLDER = "-{{suffix}}";
|
|
533
|
-
var BUNDLER_URL = "https://".concat((_a = "2.
|
|
626
|
+
var BUNDLER_URL = "https://".concat((_a = "2.22.0") === null || _a === void 0 ? void 0 : _a.replace(/\./g, "-")).concat(SUFFIX_PLACEHOLDER, "-sandpack.codesandbox.io/");
|
|
534
627
|
var SandpackRuntime = /** @class */ (function (_super) {
|
|
535
628
|
utils.__extends(SandpackRuntime, _super);
|
|
536
629
|
function SandpackRuntime(selector, sandboxSetup, options) {
|
|
537
630
|
if (options === void 0) { options = {}; }
|
|
538
631
|
var _this = _super.call(this, selector, sandboxSetup, options) || this;
|
|
632
|
+
/** Set once the teardown below has run, so a rogue document posting in a loop
|
|
633
|
+
* cannot re-run `destroy()` or re-fire the host callback. */
|
|
634
|
+
_this.refusedBoot = false;
|
|
539
635
|
/** Parent-owned Babel transpiler worker, connected to the iframe by port. */
|
|
540
636
|
_this.babelWorker = null;
|
|
637
|
+
/**
|
|
638
|
+
* Which `initialized` messages this client will honour (R3-353). Armed by
|
|
639
|
+
* {@link setLocationURLIntoIFrame} — i.e. by every navigation THIS CLIENT
|
|
640
|
+
* causes — and spent when a boot arrives. A boot the host did not cause finds
|
|
641
|
+
* nothing armed, which is the whole check. See `initialization-guard.ts` for
|
|
642
|
+
* why the parent asks "did I put you there?" rather than "where are you?".
|
|
643
|
+
*/
|
|
644
|
+
_this.initGuard = new InitializationGuard();
|
|
541
645
|
_this.getTranspilerContext = function () {
|
|
542
646
|
return new Promise(function (resolve) {
|
|
543
647
|
var unsubscribe = _this.listen(function (message) {
|
|
@@ -592,6 +696,26 @@ var SandpackRuntime = /** @class */ (function (_super) {
|
|
|
592
696
|
if (mes.type !== "initialized" || !_this.iframe.contentWindow) {
|
|
593
697
|
return;
|
|
594
698
|
}
|
|
699
|
+
// R3-353 — the re-register guard. A sandboxed frame may always navigate
|
|
700
|
+
// ITSELF (no sandbox flag governs that), and after it does, the browsing
|
|
701
|
+
// context is the same one: `iframe.contentWindow` returns the same
|
|
702
|
+
// WindowProxy, so `IFrameProtocol`'s `evt.source !== this.frameWindow`
|
|
703
|
+
// check still passes. Whatever document is in the frame now — the
|
|
704
|
+
// policy-free baseline bundler document, or a page on an origin the app
|
|
705
|
+
// chose — can therefore post `initialized` and be handed a FRESH fs port
|
|
706
|
+
// and a fresh registration, inheriting this frame's grants.
|
|
707
|
+
//
|
|
708
|
+
// That is the escalation, and it is bigger than the CSP loss the finding
|
|
709
|
+
// leads with: the CSP travels with the document, but so does the host
|
|
710
|
+
// RELATIONSHIP, and the relationship is worth more.
|
|
711
|
+
//
|
|
712
|
+
// The host cannot read a cross-origin frame's location, so it cannot ask
|
|
713
|
+
// "where are you?". It can ask "did I put you there?" — which is the same
|
|
714
|
+
// question and one it can answer: every legitimate (re)boot follows a
|
|
715
|
+
// navigation THIS CLIENT performed (the constructor, and the `refresh`
|
|
716
|
+
// dispatch), both of which go through `setLocationURLIntoIFrame`.
|
|
717
|
+
if (!_this.consumeExpectedInitialization())
|
|
718
|
+
return;
|
|
595
719
|
// this may not work with a boundedcontext, it may require an actual FS instance
|
|
596
720
|
var remotePortPromise = _this.fs.connectRemote();
|
|
597
721
|
remotePortPromise.then(function (remotePort) { return utils.__awaiter(_this, void 0, void 0, function () {
|
|
@@ -835,9 +959,42 @@ var SandpackRuntime = /** @class */ (function (_super) {
|
|
|
835
959
|
var urlSource = this.options.startRoute
|
|
836
960
|
? new URL(this.options.startRoute, this.bundlerURL).toString()
|
|
837
961
|
: this.bundlerURL;
|
|
962
|
+
// Arm one expected `initialized` (R3-353): this navigation is host-initiated,
|
|
963
|
+
// so the boot that follows it is legitimate. Every legitimate (re)boot in the
|
|
964
|
+
// system passes through here — the constructor and the `refresh` dispatch —
|
|
965
|
+
// which is exactly what makes "nothing armed" mean "not ours".
|
|
966
|
+
this.initGuard.arm();
|
|
838
967
|
(_a = this.iframe.contentWindow) === null || _a === void 0 ? void 0 : _a.location.replace(urlSource);
|
|
839
968
|
this.iframe.src = urlSource;
|
|
840
969
|
};
|
|
970
|
+
/**
|
|
971
|
+
* Spend one armed `initialized`, or refuse this boot (R3-353).
|
|
972
|
+
*
|
|
973
|
+
* Refusing is terminal for this client: the frame is blanked so the rogue
|
|
974
|
+
* document — which still holds whatever it scraped before navigating — stops
|
|
975
|
+
* executing, the client detaches, and the host is told through
|
|
976
|
+
* `onUnexpectedNavigation` so it can surface or re-create the frame. Nothing is
|
|
977
|
+
* connected and nothing is registered, so no fs port is ever minted for it.
|
|
978
|
+
*/
|
|
979
|
+
SandpackRuntime.prototype.consumeExpectedInitialization = function () {
|
|
980
|
+
var _a, _b;
|
|
981
|
+
if (this.initGuard.consume())
|
|
982
|
+
return true;
|
|
983
|
+
if (this.refusedBoot)
|
|
984
|
+
return false; // already torn down; stay quiet
|
|
985
|
+
this.refusedBoot = true;
|
|
986
|
+
console.error("[Sandpack] Refusing to register a frame that booted from a navigation " +
|
|
987
|
+
"this client did not perform (R3-353). The frame is being torn down.");
|
|
988
|
+
try {
|
|
989
|
+
this.iframe.src = "about:blank";
|
|
990
|
+
}
|
|
991
|
+
catch (_c) {
|
|
992
|
+
/* the element may already be gone */
|
|
993
|
+
}
|
|
994
|
+
this.destroy();
|
|
995
|
+
(_b = (_a = this.options).onUnexpectedNavigation) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
996
|
+
return false;
|
|
997
|
+
};
|
|
841
998
|
SandpackRuntime.prototype.destroy = function () {
|
|
842
999
|
var _a, _b, _c, _d;
|
|
843
1000
|
this.unsubscribeChannelListener();
|
|
@@ -362,6 +362,99 @@ function handleImmutableFetch(url, integrity) {
|
|
|
362
362
|
});
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
+
/**
|
|
366
|
+
* The re-register guard (R3-353; `TRUST_MODES_SPEC` §6, `UI_AS_APPS_SPEC` §G1a).
|
|
367
|
+
*
|
|
368
|
+
* ## What it defends
|
|
369
|
+
*
|
|
370
|
+
* A sandboxed frame may always navigate **itself** — no sandbox flag governs
|
|
371
|
+
* that, and `navigate-to` was dropped from CSP3, so neither half of the M3
|
|
372
|
+
* containment can prevent it. The finding that led here reads that as an egress
|
|
373
|
+
* problem (the M3 CSP travels with the birth document, so a frame that
|
|
374
|
+
* re-births itself at the policy-free baseline document gets unrestricted
|
|
375
|
+
* `connect-src` back). The bigger half is that **the host relationship travels
|
|
376
|
+
* with the document too, and the browsing context does not change**:
|
|
377
|
+
*
|
|
378
|
+
* - `iframe.contentWindow` returns the SAME `WindowProxy` across a navigation,
|
|
379
|
+
* so `IFrameProtocol`'s `evt.source !== this.frameWindow` intake check — which
|
|
380
|
+
* is correct, and is the only identity the parent has — still passes;
|
|
381
|
+
* - the client's `initialized` handler is a plain listener with no notion of how
|
|
382
|
+
* many boots it has seen, so it re-runs `fs.connectRemote()` and `register(…)`;
|
|
383
|
+
* - whatever document is in the frame now — the baseline bundler document, or a
|
|
384
|
+
* page on an origin the app chose — is therefore handed a **fresh fs port** and
|
|
385
|
+
* a fresh registration, inheriting the frame's grants.
|
|
386
|
+
*
|
|
387
|
+
* So the escalation is not "a one-shot GET carrying a small secret" (the residual
|
|
388
|
+
* `TRUST_MODES_SPEC` §6 books); it is a persistent execution context, possibly at
|
|
389
|
+
* an attacker's own origin, still attached to the host with the victim frame's
|
|
390
|
+
* authority.
|
|
391
|
+
*
|
|
392
|
+
* ## Why it is shaped as a counter
|
|
393
|
+
*
|
|
394
|
+
* The parent cannot read a cross-origin frame's `location`, so it cannot ask
|
|
395
|
+
* *"where are you?"*. It can ask *"did I put you there?"* — the same question,
|
|
396
|
+
* and one it can answer without reading anything: every legitimate boot follows a
|
|
397
|
+
* navigation the CLIENT performed (its constructor, and its `refresh` dispatch,
|
|
398
|
+
* both through `setLocationURLIntoIFrame`). Arm on navigate, spend on boot; a
|
|
399
|
+
* boot with nothing armed was not ours.
|
|
400
|
+
*
|
|
401
|
+
* A counter rather than a boolean because a rapid navigate–navigate–boot–boot
|
|
402
|
+
* sequence is legitimate and must not eat its own credit. A latch rather than
|
|
403
|
+
* decrement-below-zero because a refusal is terminal: once a rogue document has
|
|
404
|
+
* been refused, nothing it posts may re-arm anything.
|
|
405
|
+
*
|
|
406
|
+
* Kept framework-free and separate from `SandpackRuntime` so the decision can be
|
|
407
|
+
* driven directly by tests — the client itself needs a real `SandpackFS`, a Babel
|
|
408
|
+
* worker and a live iframe to construct.
|
|
409
|
+
*/
|
|
410
|
+
var InitializationGuard = /** @class */ (function () {
|
|
411
|
+
function InitializationGuard() {
|
|
412
|
+
this.expected = 0;
|
|
413
|
+
this.detached = false;
|
|
414
|
+
}
|
|
415
|
+
/** Record that the host has navigated the frame, so ONE boot is now expected. */
|
|
416
|
+
InitializationGuard.prototype.arm = function () {
|
|
417
|
+
if (this.detached)
|
|
418
|
+
return;
|
|
419
|
+
this.expected++;
|
|
420
|
+
};
|
|
421
|
+
/**
|
|
422
|
+
* Spend one armed boot. Returns `false` when this boot was not caused by the
|
|
423
|
+
* host — the caller must then refuse to connect or register anything.
|
|
424
|
+
*
|
|
425
|
+
* The first `false` **detaches** the guard permanently: every later call
|
|
426
|
+
* returns `false` too, including after an `arm()`, so a refused frame cannot be
|
|
427
|
+
* brought back by any sequence of messages.
|
|
428
|
+
*/
|
|
429
|
+
InitializationGuard.prototype.consume = function () {
|
|
430
|
+
if (this.detached)
|
|
431
|
+
return false;
|
|
432
|
+
if (this.expected > 0) {
|
|
433
|
+
this.expected--;
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
this.detached = true;
|
|
437
|
+
return false;
|
|
438
|
+
};
|
|
439
|
+
Object.defineProperty(InitializationGuard.prototype, "isDetached", {
|
|
440
|
+
/** True once an unexpected boot has been refused. Terminal. */
|
|
441
|
+
get: function () {
|
|
442
|
+
return this.detached;
|
|
443
|
+
},
|
|
444
|
+
enumerable: false,
|
|
445
|
+
configurable: true
|
|
446
|
+
});
|
|
447
|
+
Object.defineProperty(InitializationGuard.prototype, "pending", {
|
|
448
|
+
/** How many host-initiated boots are still outstanding (diagnostics/tests). */
|
|
449
|
+
get: function () {
|
|
450
|
+
return this.expected;
|
|
451
|
+
},
|
|
452
|
+
enumerable: false,
|
|
453
|
+
configurable: true
|
|
454
|
+
});
|
|
455
|
+
return InitializationGuard;
|
|
456
|
+
}());
|
|
457
|
+
|
|
365
458
|
var extensionMap = new Map();
|
|
366
459
|
var entries = Object.entries(mimeDB);
|
|
367
460
|
for (var _i = 0, entries_1 = entries; _i < entries_1.length; _i++) {
|
|
@@ -528,14 +621,25 @@ function getExtension(filepath) {
|
|
|
528
621
|
|
|
529
622
|
var _a;
|
|
530
623
|
var SUFFIX_PLACEHOLDER = "-{{suffix}}";
|
|
531
|
-
var BUNDLER_URL = "https://".concat((_a = "2.
|
|
624
|
+
var BUNDLER_URL = "https://".concat((_a = "2.22.0") === null || _a === void 0 ? void 0 : _a.replace(/\./g, "-")).concat(SUFFIX_PLACEHOLDER, "-sandpack.codesandbox.io/");
|
|
532
625
|
var SandpackRuntime = /** @class */ (function (_super) {
|
|
533
626
|
__extends(SandpackRuntime, _super);
|
|
534
627
|
function SandpackRuntime(selector, sandboxSetup, options) {
|
|
535
628
|
if (options === void 0) { options = {}; }
|
|
536
629
|
var _this = _super.call(this, selector, sandboxSetup, options) || this;
|
|
630
|
+
/** Set once the teardown below has run, so a rogue document posting in a loop
|
|
631
|
+
* cannot re-run `destroy()` or re-fire the host callback. */
|
|
632
|
+
_this.refusedBoot = false;
|
|
537
633
|
/** Parent-owned Babel transpiler worker, connected to the iframe by port. */
|
|
538
634
|
_this.babelWorker = null;
|
|
635
|
+
/**
|
|
636
|
+
* Which `initialized` messages this client will honour (R3-353). Armed by
|
|
637
|
+
* {@link setLocationURLIntoIFrame} — i.e. by every navigation THIS CLIENT
|
|
638
|
+
* causes — and spent when a boot arrives. A boot the host did not cause finds
|
|
639
|
+
* nothing armed, which is the whole check. See `initialization-guard.ts` for
|
|
640
|
+
* why the parent asks "did I put you there?" rather than "where are you?".
|
|
641
|
+
*/
|
|
642
|
+
_this.initGuard = new InitializationGuard();
|
|
539
643
|
_this.getTranspilerContext = function () {
|
|
540
644
|
return new Promise(function (resolve) {
|
|
541
645
|
var unsubscribe = _this.listen(function (message) {
|
|
@@ -590,6 +694,26 @@ var SandpackRuntime = /** @class */ (function (_super) {
|
|
|
590
694
|
if (mes.type !== "initialized" || !_this.iframe.contentWindow) {
|
|
591
695
|
return;
|
|
592
696
|
}
|
|
697
|
+
// R3-353 — the re-register guard. A sandboxed frame may always navigate
|
|
698
|
+
// ITSELF (no sandbox flag governs that), and after it does, the browsing
|
|
699
|
+
// context is the same one: `iframe.contentWindow` returns the same
|
|
700
|
+
// WindowProxy, so `IFrameProtocol`'s `evt.source !== this.frameWindow`
|
|
701
|
+
// check still passes. Whatever document is in the frame now — the
|
|
702
|
+
// policy-free baseline bundler document, or a page on an origin the app
|
|
703
|
+
// chose — can therefore post `initialized` and be handed a FRESH fs port
|
|
704
|
+
// and a fresh registration, inheriting this frame's grants.
|
|
705
|
+
//
|
|
706
|
+
// That is the escalation, and it is bigger than the CSP loss the finding
|
|
707
|
+
// leads with: the CSP travels with the document, but so does the host
|
|
708
|
+
// RELATIONSHIP, and the relationship is worth more.
|
|
709
|
+
//
|
|
710
|
+
// The host cannot read a cross-origin frame's location, so it cannot ask
|
|
711
|
+
// "where are you?". It can ask "did I put you there?" — which is the same
|
|
712
|
+
// question and one it can answer: every legitimate (re)boot follows a
|
|
713
|
+
// navigation THIS CLIENT performed (the constructor, and the `refresh`
|
|
714
|
+
// dispatch), both of which go through `setLocationURLIntoIFrame`.
|
|
715
|
+
if (!_this.consumeExpectedInitialization())
|
|
716
|
+
return;
|
|
593
717
|
// this may not work with a boundedcontext, it may require an actual FS instance
|
|
594
718
|
var remotePortPromise = _this.fs.connectRemote();
|
|
595
719
|
remotePortPromise.then(function (remotePort) { return __awaiter(_this, void 0, void 0, function () {
|
|
@@ -833,9 +957,42 @@ var SandpackRuntime = /** @class */ (function (_super) {
|
|
|
833
957
|
var urlSource = this.options.startRoute
|
|
834
958
|
? new URL(this.options.startRoute, this.bundlerURL).toString()
|
|
835
959
|
: this.bundlerURL;
|
|
960
|
+
// Arm one expected `initialized` (R3-353): this navigation is host-initiated,
|
|
961
|
+
// so the boot that follows it is legitimate. Every legitimate (re)boot in the
|
|
962
|
+
// system passes through here — the constructor and the `refresh` dispatch —
|
|
963
|
+
// which is exactly what makes "nothing armed" mean "not ours".
|
|
964
|
+
this.initGuard.arm();
|
|
836
965
|
(_a = this.iframe.contentWindow) === null || _a === void 0 ? void 0 : _a.location.replace(urlSource);
|
|
837
966
|
this.iframe.src = urlSource;
|
|
838
967
|
};
|
|
968
|
+
/**
|
|
969
|
+
* Spend one armed `initialized`, or refuse this boot (R3-353).
|
|
970
|
+
*
|
|
971
|
+
* Refusing is terminal for this client: the frame is blanked so the rogue
|
|
972
|
+
* document — which still holds whatever it scraped before navigating — stops
|
|
973
|
+
* executing, the client detaches, and the host is told through
|
|
974
|
+
* `onUnexpectedNavigation` so it can surface or re-create the frame. Nothing is
|
|
975
|
+
* connected and nothing is registered, so no fs port is ever minted for it.
|
|
976
|
+
*/
|
|
977
|
+
SandpackRuntime.prototype.consumeExpectedInitialization = function () {
|
|
978
|
+
var _a, _b;
|
|
979
|
+
if (this.initGuard.consume())
|
|
980
|
+
return true;
|
|
981
|
+
if (this.refusedBoot)
|
|
982
|
+
return false; // already torn down; stay quiet
|
|
983
|
+
this.refusedBoot = true;
|
|
984
|
+
console.error("[Sandpack] Refusing to register a frame that booted from a navigation " +
|
|
985
|
+
"this client did not perform (R3-353). The frame is being torn down.");
|
|
986
|
+
try {
|
|
987
|
+
this.iframe.src = "about:blank";
|
|
988
|
+
}
|
|
989
|
+
catch (_c) {
|
|
990
|
+
/* the element may already be gone */
|
|
991
|
+
}
|
|
992
|
+
this.destroy();
|
|
993
|
+
(_b = (_a = this.options).onUnexpectedNavigation) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
994
|
+
return false;
|
|
995
|
+
};
|
|
839
996
|
SandpackRuntime.prototype.destroy = function () {
|
|
840
997
|
var _a, _b, _c, _d;
|
|
841
998
|
this.unsubscribeChannelListener();
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The re-register guard (R3-353; `TRUST_MODES_SPEC` §6, `UI_AS_APPS_SPEC` §G1a).
|
|
3
|
+
*
|
|
4
|
+
* ## What it defends
|
|
5
|
+
*
|
|
6
|
+
* A sandboxed frame may always navigate **itself** — no sandbox flag governs
|
|
7
|
+
* that, and `navigate-to` was dropped from CSP3, so neither half of the M3
|
|
8
|
+
* containment can prevent it. The finding that led here reads that as an egress
|
|
9
|
+
* problem (the M3 CSP travels with the birth document, so a frame that
|
|
10
|
+
* re-births itself at the policy-free baseline document gets unrestricted
|
|
11
|
+
* `connect-src` back). The bigger half is that **the host relationship travels
|
|
12
|
+
* with the document too, and the browsing context does not change**:
|
|
13
|
+
*
|
|
14
|
+
* - `iframe.contentWindow` returns the SAME `WindowProxy` across a navigation,
|
|
15
|
+
* so `IFrameProtocol`'s `evt.source !== this.frameWindow` intake check — which
|
|
16
|
+
* is correct, and is the only identity the parent has — still passes;
|
|
17
|
+
* - the client's `initialized` handler is a plain listener with no notion of how
|
|
18
|
+
* many boots it has seen, so it re-runs `fs.connectRemote()` and `register(…)`;
|
|
19
|
+
* - whatever document is in the frame now — the baseline bundler document, or a
|
|
20
|
+
* page on an origin the app chose — is therefore handed a **fresh fs port** and
|
|
21
|
+
* a fresh registration, inheriting the frame's grants.
|
|
22
|
+
*
|
|
23
|
+
* So the escalation is not "a one-shot GET carrying a small secret" (the residual
|
|
24
|
+
* `TRUST_MODES_SPEC` §6 books); it is a persistent execution context, possibly at
|
|
25
|
+
* an attacker's own origin, still attached to the host with the victim frame's
|
|
26
|
+
* authority.
|
|
27
|
+
*
|
|
28
|
+
* ## Why it is shaped as a counter
|
|
29
|
+
*
|
|
30
|
+
* The parent cannot read a cross-origin frame's `location`, so it cannot ask
|
|
31
|
+
* *"where are you?"*. It can ask *"did I put you there?"* — the same question,
|
|
32
|
+
* and one it can answer without reading anything: every legitimate boot follows a
|
|
33
|
+
* navigation the CLIENT performed (its constructor, and its `refresh` dispatch,
|
|
34
|
+
* both through `setLocationURLIntoIFrame`). Arm on navigate, spend on boot; a
|
|
35
|
+
* boot with nothing armed was not ours.
|
|
36
|
+
*
|
|
37
|
+
* A counter rather than a boolean because a rapid navigate–navigate–boot–boot
|
|
38
|
+
* sequence is legitimate and must not eat its own credit. A latch rather than
|
|
39
|
+
* decrement-below-zero because a refusal is terminal: once a rogue document has
|
|
40
|
+
* been refused, nothing it posts may re-arm anything.
|
|
41
|
+
*
|
|
42
|
+
* Kept framework-free and separate from `SandpackRuntime` so the decision can be
|
|
43
|
+
* driven directly by tests — the client itself needs a real `SandpackFS`, a Babel
|
|
44
|
+
* worker and a live iframe to construct.
|
|
45
|
+
*/
|
|
46
|
+
export declare class InitializationGuard {
|
|
47
|
+
private expected;
|
|
48
|
+
private detached;
|
|
49
|
+
/** Record that the host has navigated the frame, so ONE boot is now expected. */
|
|
50
|
+
arm(): void;
|
|
51
|
+
/**
|
|
52
|
+
* Spend one armed boot. Returns `false` when this boot was not caused by the
|
|
53
|
+
* host — the caller must then refuse to connect or register anything.
|
|
54
|
+
*
|
|
55
|
+
* The first `false` **detaches** the guard permanently: every later call
|
|
56
|
+
* returns `false` too, including after an `arm()`, so a refused frame cannot be
|
|
57
|
+
* brought back by any sequence of messages.
|
|
58
|
+
*/
|
|
59
|
+
consume(): boolean;
|
|
60
|
+
/** True once an unexpected boot has been refused. Terminal. */
|
|
61
|
+
get isDetached(): boolean;
|
|
62
|
+
/** How many host-initiated boots are still outstanding (diagnostics/tests). */
|
|
63
|
+
get pending(): number;
|
|
64
|
+
}
|
|
@@ -28,6 +28,11 @@ export type SandpackRuntimeMessage = BaseSandpackMessage & ({
|
|
|
28
28
|
url: string;
|
|
29
29
|
back: boolean;
|
|
30
30
|
forward: boolean;
|
|
31
|
+
/** R3-268: the tri-state viewed-document declaration — ABSENT means
|
|
32
|
+
* "derive from the URL's `files/` suffix convention", `null` means
|
|
33
|
+
* "this view shows no file" (clears the explorer highlight), a string
|
|
34
|
+
* is a working-tree repo-relative path the destination renders. */
|
|
35
|
+
viewedDocument?: string | null;
|
|
31
36
|
} | {
|
|
32
37
|
type: "resize";
|
|
33
38
|
height: number;
|
package/dist/types.d.ts
CHANGED
|
@@ -37,6 +37,18 @@ export interface ClientOptions {
|
|
|
37
37
|
* `allow-same-origin`). Required for the runtime client to transpile.
|
|
38
38
|
*/
|
|
39
39
|
babelWorkerURL?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Called when the client REFUSES to register a frame because it booted from a
|
|
42
|
+
* navigation the client did not perform (R3-353 — a sandboxed frame may always
|
|
43
|
+
* navigate itself, and the browsing context survives, so a page the app chose
|
|
44
|
+
* could otherwise post `initialized` and be handed this frame's grants).
|
|
45
|
+
*
|
|
46
|
+
* By the time this fires the frame has been blanked and the client destroyed:
|
|
47
|
+
* nothing was connected and no fs port was minted. The host's job is to surface
|
|
48
|
+
* it (and/or re-create the frame from a clean state), not to decide whether to
|
|
49
|
+
* allow it — that decision has already been made, fail-closed.
|
|
50
|
+
*/
|
|
51
|
+
onUnexpectedNavigation?: () => void;
|
|
40
52
|
/**
|
|
41
53
|
* Level of logging to do in the bundler
|
|
42
54
|
*/
|