@botiverse/porthole 0.1.0 → 0.1.2
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/bridge/bridge.js +20 -6
- package/dist/bridge/types.d.ts +19 -3
- package/dist/compile/policy.d.ts +6 -5
- package/dist/compile/policy.js +10 -12
- package/dist/config/schema.d.ts +29 -0
- package/dist/config/schema.js +16 -1
- package/dist/config/validate.d.ts +3 -1
- package/dist/config/validate.js +55 -10
- package/dist/conformance/behavior.js +50 -0
- package/dist/conformance/index.d.ts +11 -5
- package/dist/conformance/index.js +2 -2
- package/dist/conformance/probes.d.ts +26 -2
- package/dist/conformance/probes.js +51 -40
- package/dist/conformance/served.d.ts +7 -0
- package/dist/conformance/served.js +31 -7
- package/dist/guide/guide.js +6 -2
- package/dist/host/create-host.js +46 -2
- package/dist/host/locate.js +6 -1
- package/dist/host/outcome.d.ts +25 -0
- package/dist/host/outcome.js +147 -0
- package/dist/host/types.d.ts +39 -2
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -1
- package/dist/isolation/served.d.ts +3 -0
- package/dist/isolation/served.js +20 -0
- package/dist/isolation/types.d.ts +9 -0
- package/dist/isolation/types.js +2 -1
- package/dist/platform/types.d.ts +7 -0
- package/dist/platform/web.js +15 -2
- package/dist/presets/presets.d.ts +11 -3
- package/dist/presets/presets.js +17 -1
- package/dist/react/frame.d.ts +16 -0
- package/dist/react/frame.js +44 -10
- package/dist/server/crypto.d.ts +19 -0
- package/dist/server/crypto.js +144 -0
- package/dist/server/index.d.ts +12 -4
- package/dist/server/index.js +11 -3
- package/dist/server/keys.js +12 -9
- package/dist/server/naming.d.ts +17 -0
- package/dist/server/naming.js +34 -0
- package/dist/server/pages.d.ts +11 -6
- package/dist/server/pages.js +86 -12
- package/dist/server/server.js +191 -23
- package/dist/server/tickets.d.ts +24 -0
- package/dist/server/tickets.js +46 -0
- package/dist/server/types.d.ts +76 -5
- package/dist/shell/document.d.ts +3 -2
- package/dist/shell/document.js +4 -3
- package/dist/shell/plan.d.ts +2 -0
- package/dist/shell/plan.js +1 -0
- package/dist/shell/runtime.js +20 -4
- package/package.json +2 -9
- package/src/bridge/bridge.ts +28 -6
- package/src/bridge/types.ts +21 -3
- package/src/compile/policy.ts +10 -12
- package/src/config/schema.ts +38 -0
- package/src/config/validate.ts +63 -14
- package/src/conformance/behavior.ts +55 -0
- package/src/conformance/index.ts +18 -4
- package/src/conformance/probes.ts +88 -52
- package/src/conformance/served.ts +37 -8
- package/src/guide/guide.ts +8 -2
- package/src/host/create-host.ts +47 -2
- package/src/host/locate.ts +6 -1
- package/src/host/outcome.ts +177 -0
- package/src/host/types.ts +42 -2
- package/src/index.ts +19 -1
- package/src/isolation/served.ts +24 -0
- package/src/isolation/types.ts +15 -1
- package/src/platform/types.ts +7 -0
- package/src/platform/web.ts +15 -2
- package/src/presets/presets.ts +31 -3
- package/src/react/frame.tsx +78 -14
- package/src/server/crypto.ts +166 -0
- package/src/server/index.ts +20 -3
- package/src/server/keys.ts +21 -13
- package/src/server/naming.ts +42 -0
- package/src/server/pages.ts +106 -11
- package/src/server/server.ts +233 -23
- package/src/server/tickets.ts +65 -0
- package/src/server/types.ts +84 -7
- package/src/shell/document.ts +7 -6
- package/src/shell/plan.ts +3 -0
- package/src/shell/runtime.ts +20 -4
package/src/server/server.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { compile, type CompiledConfig } from "../compile/compile.ts";
|
|
2
2
|
import type { DisplayMode, EgressConfig } from "../config/schema.ts";
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
hostOriginProblem,
|
|
5
5
|
maybeSameSite,
|
|
6
6
|
originMatchesSource,
|
|
7
7
|
parseOrigin,
|
|
@@ -9,9 +9,18 @@ import {
|
|
|
9
9
|
import type { FrameLocation, InstanceRef, ServedDocument } from "../isolation/types.ts";
|
|
10
10
|
import { sandboxProxyDocument } from "../mcp/proxy.ts";
|
|
11
11
|
import { createKeys, newTicket, ticketId } from "./keys.ts";
|
|
12
|
-
import {
|
|
12
|
+
import { isHostLabel } from "./naming.ts";
|
|
13
|
+
import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.ts";
|
|
14
|
+
import type { FrameBlock } from "../host/types.ts";
|
|
13
15
|
import { memoryTicketStore } from "./tickets.ts";
|
|
14
|
-
import type {
|
|
16
|
+
import type {
|
|
17
|
+
Access,
|
|
18
|
+
AuthorizeAnswer,
|
|
19
|
+
PortholeServer,
|
|
20
|
+
ServerOptions,
|
|
21
|
+
TicketRecord,
|
|
22
|
+
TicketStore,
|
|
23
|
+
} from "./types.ts";
|
|
15
24
|
|
|
16
25
|
/**
|
|
17
26
|
* Paths on a content origin. Porthole's own live under `/__porthole/`; on a url-mode app's origin
|
|
@@ -24,6 +33,9 @@ const DOCUMENT_PATH = /^\/__porthole\/d\/([a-z2-7]{16})$/u;
|
|
|
24
33
|
const ENTER_PATH = "/__porthole/enter";
|
|
25
34
|
|
|
26
35
|
const LABEL = /^[a-z2-7]{24}$/u;
|
|
36
|
+
/** A host's opaque subject and reason codes. */
|
|
37
|
+
const SUBJECT_MAX = 512;
|
|
38
|
+
const CODE = /^[a-z0-9-]{1,40}$/u;
|
|
27
39
|
const DNS_NAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/u;
|
|
28
40
|
const NAME = /^[A-Za-z0-9._:-]{1,200}$/u;
|
|
29
41
|
|
|
@@ -34,11 +46,16 @@ const MAX_SESSION_SECONDS = 30 * 24 * 3_600;
|
|
|
34
46
|
/** Browsers keep a cookie of up to 4 KB, name and attributes included. */
|
|
35
47
|
const MAX_COOKIE_BYTES = 3_800;
|
|
36
48
|
|
|
37
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* What a session cookie carries, signed: the instance, its viewer (or anonymous), the host's
|
|
51
|
+
* opaque subject, and when it stops.
|
|
52
|
+
*/
|
|
38
53
|
interface Session {
|
|
39
54
|
readonly i: InstanceRef;
|
|
40
55
|
readonly w: string;
|
|
41
56
|
readonly x: number;
|
|
57
|
+
readonly a?: 1;
|
|
58
|
+
readonly s?: string;
|
|
42
59
|
}
|
|
43
60
|
|
|
44
61
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -173,6 +190,30 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
173
190
|
throw new TypeError("content.port must be a port number");
|
|
174
191
|
}
|
|
175
192
|
const keys = createKeys(options.secret);
|
|
193
|
+
const reserved = new Set(content.reserved ?? []);
|
|
194
|
+
for (const label of reserved) {
|
|
195
|
+
if (!isHostLabel(label)) {
|
|
196
|
+
throw new TypeError(
|
|
197
|
+
`content.reserved: ${JSON.stringify(label)} is not a lowercase DNS label`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** The label a scope is served on: the host's naming, checked, or the keyed default. */
|
|
202
|
+
const nameOf = (scope: string): string => {
|
|
203
|
+
if (content.name === undefined) {
|
|
204
|
+
return keys.label(scope);
|
|
205
|
+
}
|
|
206
|
+
const label = content.name(scope);
|
|
207
|
+
if (!isHostLabel(label) || reserved.has(label)) {
|
|
208
|
+
throw new TypeError(
|
|
209
|
+
`content.name gave ${JSON.stringify(label)} for a scope: it must be one lowercase DNS label (a-z 0-9 -, at most 63, not xn--), not reserved`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return label;
|
|
213
|
+
};
|
|
214
|
+
/** Whether a request's label may be one this server named. */
|
|
215
|
+
const ours = (label: string): boolean =>
|
|
216
|
+
!reserved.has(label) && (content.name === undefined ? LABEL.test(label) : isHostLabel(label));
|
|
176
217
|
const secure = content.scheme === "https";
|
|
177
218
|
const defaultPort = secure ? 443 : 80;
|
|
178
219
|
const portPart =
|
|
@@ -182,14 +223,25 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
182
223
|
const contentSource = `${content.scheme}://*.${domain}${portPart}`;
|
|
183
224
|
const sampleHost = `${keys.label("")}.${domain}`;
|
|
184
225
|
const sampleOrigin = originOfLabel(keys.label(""));
|
|
226
|
+
const authorize = options.authorize;
|
|
227
|
+
const authorizeTtl = authorize?.ttlSeconds ?? 0;
|
|
228
|
+
if (!Number.isInteger(authorizeTtl) || authorizeTtl < 0 || authorizeTtl > 300) {
|
|
229
|
+
throw new TypeError("authorize.ttlSeconds must be an integer from 0 to 300");
|
|
230
|
+
}
|
|
231
|
+
const answers = new Map<string, { readonly answer: AuthorizeAnswer; readonly until: number }>();
|
|
185
232
|
|
|
186
233
|
if (options.hostOrigins.length === 0) {
|
|
187
234
|
throw new TypeError("hostOrigins must name the origins that embed content");
|
|
188
235
|
}
|
|
189
236
|
for (const origin of options.hostOrigins) {
|
|
190
|
-
const
|
|
237
|
+
const problem = hostOriginProblem(origin);
|
|
238
|
+
if (problem !== null) {
|
|
239
|
+
throw new TypeError(`hostOrigins: ${problem}`);
|
|
240
|
+
}
|
|
241
|
+
// A desktop app's own scheme (ferry://app) is never the same site as web content.
|
|
242
|
+
const parsed = parseOrigin(origin);
|
|
191
243
|
if (parsed === null) {
|
|
192
|
-
|
|
244
|
+
continue;
|
|
193
245
|
}
|
|
194
246
|
if (
|
|
195
247
|
parsed.host === domain ||
|
|
@@ -209,8 +261,70 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
209
261
|
}
|
|
210
262
|
const tickets: TicketStore = options.tickets ?? memoryTicketStore();
|
|
211
263
|
|
|
212
|
-
|
|
213
|
-
|
|
264
|
+
/** A page saying why there is nothing to show, told to the host too (its reason, P040). */
|
|
265
|
+
const page = (
|
|
266
|
+
status: number,
|
|
267
|
+
title: string,
|
|
268
|
+
detail: string,
|
|
269
|
+
method?: string,
|
|
270
|
+
reason: FrameBlock = status === 404 ? "not-found" : status === 502 ? "upstream" : "error",
|
|
271
|
+
code?: string,
|
|
272
|
+
): Response =>
|
|
273
|
+
statusPage(status, title, detail, frameAncestors, method, reason, options.hostOrigins, code);
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The host's check on a request its cookie (or ticket) let in (P050): null to go on, or the
|
|
277
|
+
* page saying it's denied. Answers are reused only for the host's own `ttlSeconds`.
|
|
278
|
+
*/
|
|
279
|
+
async function denied(request: Request, access: Access): Promise<Response | null> {
|
|
280
|
+
if (authorize === undefined) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
const key = JSON.stringify([
|
|
284
|
+
keys.instanceKey(access.instance),
|
|
285
|
+
access.viewer,
|
|
286
|
+
access.subject ?? null,
|
|
287
|
+
access.expiresAt,
|
|
288
|
+
]);
|
|
289
|
+
const at = Date.now();
|
|
290
|
+
let answer = authorizeTtl > 0 ? answers.get(key) : undefined;
|
|
291
|
+
if (answer === undefined || answer.until <= at) {
|
|
292
|
+
let given: AuthorizeAnswer;
|
|
293
|
+
try {
|
|
294
|
+
given = await authorize.check({
|
|
295
|
+
scope: access.instance.scope,
|
|
296
|
+
instance: access.instance,
|
|
297
|
+
viewer: access.viewer,
|
|
298
|
+
anonymous: access.anonymous === true,
|
|
299
|
+
...(access.subject === undefined ? {} : { subject: access.subject }),
|
|
300
|
+
request,
|
|
301
|
+
});
|
|
302
|
+
} catch {
|
|
303
|
+
given = { allow: false };
|
|
304
|
+
}
|
|
305
|
+
answer = { answer: given, until: at + authorizeTtl * 1_000 };
|
|
306
|
+
if (authorizeTtl > 0) {
|
|
307
|
+
if (answers.size > 10_000) {
|
|
308
|
+
answers.clear();
|
|
309
|
+
}
|
|
310
|
+
answers.set(key, answer);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Only an explicit allow lets the request on: anything else the check gives is a deny.
|
|
314
|
+
const given = answer.answer as Partial<{ allow: unknown; code: unknown }> | null | undefined;
|
|
315
|
+
if (given?.allow === true) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
const code = typeof given?.code === "string" && CODE.test(given.code) ? given.code : undefined;
|
|
319
|
+
return page(
|
|
320
|
+
403,
|
|
321
|
+
"You can't open this anymore",
|
|
322
|
+
"The app that showed it no longer lets you see it here.",
|
|
323
|
+
request.method.toUpperCase(),
|
|
324
|
+
"denied",
|
|
325
|
+
code,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
214
328
|
|
|
215
329
|
/** The config a served document is served under, checked to fit it and this server. */
|
|
216
330
|
function configFor(name: string, kind: keyof typeof MODES): CompiledConfig {
|
|
@@ -318,6 +432,8 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
318
432
|
i: record.instance,
|
|
319
433
|
w: record.viewer,
|
|
320
434
|
x: Date.now() + sessionSeconds * 1_000,
|
|
435
|
+
...(record.anonymous === true ? { a: 1 as const } : {}),
|
|
436
|
+
...(record.subject === undefined ? {} : { s: record.subject }),
|
|
321
437
|
};
|
|
322
438
|
const framed = request.headers.get("sec-fetch-dest") !== "document";
|
|
323
439
|
return [
|
|
@@ -343,7 +459,8 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
343
459
|
!isRecord(session) ||
|
|
344
460
|
typeof session.w !== "string" ||
|
|
345
461
|
typeof session.x !== "number" ||
|
|
346
|
-
session.x <= Date.now()
|
|
462
|
+
session.x <= Date.now() ||
|
|
463
|
+
(session.s !== undefined && typeof session.s !== "string")
|
|
347
464
|
) {
|
|
348
465
|
continue;
|
|
349
466
|
}
|
|
@@ -353,14 +470,27 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
353
470
|
} catch {
|
|
354
471
|
continue;
|
|
355
472
|
}
|
|
473
|
+
let named: string;
|
|
474
|
+
try {
|
|
475
|
+
named = nameOf(instance.scope);
|
|
476
|
+
} catch {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
356
479
|
if (
|
|
357
|
-
|
|
480
|
+
named !== label ||
|
|
358
481
|
(key === null ? instance.document.kind !== "external" : keys.instanceKey(instance) !== key)
|
|
359
482
|
) {
|
|
360
483
|
continue;
|
|
361
484
|
}
|
|
362
485
|
if (best === null || session.x > best.expiresAt) {
|
|
363
|
-
best = {
|
|
486
|
+
best = {
|
|
487
|
+
instance,
|
|
488
|
+
viewer: session.w,
|
|
489
|
+
origin: originOfLabel(label),
|
|
490
|
+
expiresAt: session.x,
|
|
491
|
+
...(session.a === 1 ? { anonymous: true } : {}),
|
|
492
|
+
...(typeof session.s === "string" ? { subject: session.s } : {}),
|
|
493
|
+
};
|
|
364
494
|
}
|
|
365
495
|
}
|
|
366
496
|
return best;
|
|
@@ -377,7 +507,7 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
377
507
|
}
|
|
378
508
|
try {
|
|
379
509
|
const instance = cleanInstance(record.instance);
|
|
380
|
-
return
|
|
510
|
+
return nameOf(instance.scope) === label ? { ...record, instance } : null;
|
|
381
511
|
} catch {
|
|
382
512
|
return null;
|
|
383
513
|
}
|
|
@@ -419,6 +549,8 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
419
549
|
viewer: record.viewer,
|
|
420
550
|
origin: originOfLabel(label),
|
|
421
551
|
expiresAt: Date.now() + (record.sessionSeconds ?? DEFAULT_SESSION_SECONDS) * 1_000,
|
|
552
|
+
...(record.anonymous === true ? { anonymous: true } : {}),
|
|
553
|
+
...(record.subject === undefined ? {} : { subject: record.subject }),
|
|
422
554
|
};
|
|
423
555
|
}
|
|
424
556
|
access ??= accessOf(request, label, key);
|
|
@@ -428,8 +560,13 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
428
560
|
"This link has been used",
|
|
429
561
|
"Content here opens once per link. Open it again from the app that showed it.",
|
|
430
562
|
method,
|
|
563
|
+
"session",
|
|
431
564
|
);
|
|
432
565
|
}
|
|
566
|
+
const refusal = await denied(request, access);
|
|
567
|
+
if (refusal !== null) {
|
|
568
|
+
return refusal;
|
|
569
|
+
}
|
|
433
570
|
const served = await documentOf(access.instance);
|
|
434
571
|
if (served === null) {
|
|
435
572
|
return page(404, "Not found", "This page, or this version of it, doesn't exist.", method);
|
|
@@ -452,13 +589,37 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
452
589
|
403,
|
|
453
590
|
"This link has been used",
|
|
454
591
|
"A link into an app works once, for a short time. Open it again from the app that showed it.",
|
|
592
|
+
method,
|
|
593
|
+
"session",
|
|
455
594
|
);
|
|
456
595
|
}
|
|
596
|
+
const refusal = await denied(request, {
|
|
597
|
+
instance: record.instance,
|
|
598
|
+
viewer: record.viewer,
|
|
599
|
+
origin: originOfLabel(label),
|
|
600
|
+
expiresAt: record.expiresAt,
|
|
601
|
+
...(record.subject === undefined ? {} : { subject: record.subject }),
|
|
602
|
+
});
|
|
603
|
+
if (refusal !== null) {
|
|
604
|
+
return refusal;
|
|
605
|
+
}
|
|
457
606
|
const target = new URL(record.instance.document.url);
|
|
607
|
+
const landing = `${target.pathname}${target.search}${target.hash}`;
|
|
608
|
+
// In a frame, a page enters the app: it can tell whether the browser kept the cookie (and tell
|
|
609
|
+
// the host), where a redirect would leave a frame without one showing a refusal. On a tab of
|
|
610
|
+
// its own, a redirect.
|
|
611
|
+
if (request.headers.get("sec-fetch-dest") !== "document") {
|
|
612
|
+
return enterPage(
|
|
613
|
+
landing,
|
|
614
|
+
frameAncestors,
|
|
615
|
+
options.hostOrigins,
|
|
616
|
+
sessionCookie(request, record),
|
|
617
|
+
);
|
|
618
|
+
}
|
|
458
619
|
return new Response(null, {
|
|
459
620
|
status: 303,
|
|
460
621
|
headers: {
|
|
461
|
-
location:
|
|
622
|
+
location: landing,
|
|
462
623
|
"set-cookie": sessionCookie(request, record),
|
|
463
624
|
"cache-control": "no-store",
|
|
464
625
|
"referrer-policy": "no-referrer",
|
|
@@ -508,8 +669,13 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
508
669
|
"Open this from the app",
|
|
509
670
|
"This address shows an app only to the people the app opens it for.",
|
|
510
671
|
method,
|
|
672
|
+
"session",
|
|
511
673
|
);
|
|
512
674
|
}
|
|
675
|
+
const refusal = await denied(request, access);
|
|
676
|
+
if (refusal !== null) {
|
|
677
|
+
return refusal;
|
|
678
|
+
}
|
|
513
679
|
if (options.upstream === undefined) {
|
|
514
680
|
return page(502, "Nothing serves this app", "The server has no upstream for apps.", method);
|
|
515
681
|
}
|
|
@@ -532,23 +698,43 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
532
698
|
if (host !== domain && !host.endsWith(`.${domain}`)) {
|
|
533
699
|
return null;
|
|
534
700
|
}
|
|
535
|
-
|
|
701
|
+
const label = host.slice(0, Math.max(0, host.length - domain.length - 1));
|
|
702
|
+
// A reserved label is another server's namespace (`f` for `<x>.f.<domain>`): it and the names
|
|
703
|
+
// under it go to the host's own routes. Any other deeper name is ours to refuse.
|
|
704
|
+
if (reserved.has(label.split(".").at(-1) ?? "")) {
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
return { url, label };
|
|
536
708
|
}
|
|
537
709
|
|
|
538
710
|
return {
|
|
539
711
|
contentSource,
|
|
540
712
|
|
|
541
|
-
originFor: (scope) => originOfLabel(
|
|
713
|
+
originFor: (scope) => originOfLabel(nameOf(scope)),
|
|
542
714
|
|
|
543
715
|
async issue(instance, grant): Promise<FrameLocation> {
|
|
544
716
|
const clean = cleanInstance(instance);
|
|
545
717
|
check(clean);
|
|
546
|
-
|
|
718
|
+
const anonymous = grant.anonymous === true;
|
|
719
|
+
if (anonymous) {
|
|
720
|
+
if (grant.viewer !== undefined) {
|
|
721
|
+
throw new TypeError("An anonymous grant names no viewer");
|
|
722
|
+
}
|
|
723
|
+
if (clean.document.kind === "external") {
|
|
724
|
+
throw new TypeError("A url-mode app is never opened anonymously (P046)");
|
|
725
|
+
}
|
|
726
|
+
} else if (
|
|
547
727
|
typeof grant.viewer !== "string" ||
|
|
548
728
|
grant.viewer.length === 0 ||
|
|
549
729
|
grant.viewer.length > 512
|
|
550
730
|
) {
|
|
551
|
-
throw new TypeError("A grant's viewer is 1 to 512 characters");
|
|
731
|
+
throw new TypeError("A grant's viewer is 1 to 512 characters (or the grant is anonymous)");
|
|
732
|
+
}
|
|
733
|
+
if (
|
|
734
|
+
grant.subject !== undefined &&
|
|
735
|
+
(typeof grant.subject !== "string" || grant.subject.length > SUBJECT_MAX)
|
|
736
|
+
) {
|
|
737
|
+
throw new TypeError(`A grant's subject is a string of at most ${SUBJECT_MAX} characters`);
|
|
552
738
|
}
|
|
553
739
|
const ttl = seconds(
|
|
554
740
|
grant.ttlSeconds,
|
|
@@ -563,28 +749,41 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
563
749
|
"sessionSeconds",
|
|
564
750
|
);
|
|
565
751
|
const key = keys.instanceKey(clean);
|
|
566
|
-
const
|
|
752
|
+
const viewer = anonymous ? "" : grant.viewer;
|
|
753
|
+
const probe = keys.sign({
|
|
754
|
+
i: clean,
|
|
755
|
+
w: viewer,
|
|
756
|
+
x: Number.MAX_SAFE_INTEGER,
|
|
757
|
+
...(anonymous ? { a: 1 } : {}),
|
|
758
|
+
...(grant.subject === undefined ? {} : { s: grant.subject }),
|
|
759
|
+
});
|
|
567
760
|
if (cookieName(key).length + probe.length + 120 > MAX_COOKIE_BYTES) {
|
|
568
761
|
throw new TypeError("This instance is too large to carry in a cookie (fewer origins?)");
|
|
569
762
|
}
|
|
570
763
|
const ticket = newTicket();
|
|
571
764
|
await tickets.put(ticketId(ticket), {
|
|
572
765
|
instance: clean,
|
|
573
|
-
viewer
|
|
766
|
+
viewer,
|
|
574
767
|
expiresAt: Date.now() + ttl * 1_000,
|
|
575
768
|
...(grant.sessionSeconds === undefined ? {} : { sessionSeconds }),
|
|
769
|
+
...(anonymous ? { anonymous: true } : {}),
|
|
770
|
+
...(grant.subject === undefined ? {} : { subject: grant.subject }),
|
|
576
771
|
});
|
|
577
|
-
const origin = originOfLabel(
|
|
772
|
+
const origin = originOfLabel(nameOf(clean.scope));
|
|
578
773
|
const path =
|
|
579
774
|
clean.document.kind === "external"
|
|
580
775
|
? `${ENTER_PATH}?ticket=${ticket}`
|
|
581
776
|
: `${PREFIX}d/${key}?ticket=${ticket}`;
|
|
582
|
-
return { url: `${origin}${path}`, origin };
|
|
777
|
+
return { url: `${origin}${path}`, origin, reports: true };
|
|
583
778
|
},
|
|
584
779
|
|
|
585
780
|
async access(request) {
|
|
586
781
|
const at = labelOf(request);
|
|
587
|
-
|
|
782
|
+
if (at === null || !ours(at.label)) {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
const access = accessOf(request, at.label, null);
|
|
786
|
+
return access === null || (await denied(request, access)) !== null ? null : access;
|
|
588
787
|
},
|
|
589
788
|
|
|
590
789
|
async handle(request) {
|
|
@@ -595,12 +794,23 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
595
794
|
const { url, label } = at;
|
|
596
795
|
const method = request.method.toUpperCase();
|
|
597
796
|
try {
|
|
598
|
-
if (!
|
|
797
|
+
if (!ours(label)) {
|
|
599
798
|
return page(404, "Nothing here", "This address names no content.", method);
|
|
600
799
|
}
|
|
601
800
|
if (url.pathname === ENTER_PATH) {
|
|
602
801
|
return await enter(request, url, label);
|
|
603
802
|
}
|
|
803
|
+
const script = pageScript(url.pathname);
|
|
804
|
+
if (script !== null) {
|
|
805
|
+
return script;
|
|
806
|
+
}
|
|
807
|
+
if (url.pathname === SESSION_PATH) {
|
|
808
|
+
// Whether this request's cookie opens its origin's app: the entry page asks.
|
|
809
|
+
const access = accessOf(request, label, null);
|
|
810
|
+
const status =
|
|
811
|
+
access === null ? 401 : (await denied(request, access)) === null ? 204 : 403;
|
|
812
|
+
return new Response(null, { status, headers: { "cache-control": "no-store" } });
|
|
813
|
+
}
|
|
604
814
|
const document = DOCUMENT_PATH.exec(url.pathname);
|
|
605
815
|
if (document?.[1] !== undefined) {
|
|
606
816
|
return await serveDocument(request, url, label, document[1]);
|
package/src/server/tickets.ts
CHANGED
|
@@ -28,3 +28,68 @@ export function memoryTicketStore(options: { readonly now?: () => number } = {})
|
|
|
28
28
|
},
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The part of a Cloudflare D1 database (or any SQLite-like binding with the same shape) the ticket
|
|
34
|
+
* store uses: a prepared statement, bound, run or read once.
|
|
35
|
+
*/
|
|
36
|
+
export interface D1Like {
|
|
37
|
+
prepare(query: string): D1StatementLike;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface D1StatementLike {
|
|
41
|
+
bind(...values: unknown[]): D1StatementLike;
|
|
42
|
+
run(): Promise<unknown>;
|
|
43
|
+
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const TABLE = /^[a-z_][a-z0-9_]{0,62}$/u;
|
|
47
|
+
|
|
48
|
+
/** The table a D1 ticket store needs (run once, as a migration). */
|
|
49
|
+
export function d1TicketSchema(table = "porthole_tickets"): string {
|
|
50
|
+
if (!TABLE.test(table)) {
|
|
51
|
+
throw new TypeError("A ticket table is named with a-z 0-9 _");
|
|
52
|
+
}
|
|
53
|
+
return `CREATE TABLE IF NOT EXISTS ${table} (id TEXT PRIMARY KEY, record TEXT NOT NULL, expires_at INTEGER NOT NULL);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS ${table}_expires_at ON ${table} (expires_at);`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Tickets in a D1 database (P045): shared by every Worker isolate, and taken once by an atomic
|
|
59
|
+
* `DELETE … RETURNING` (one statement on D1's single primary, so two requests racing for a ticket
|
|
60
|
+
* can't both get it). Expired tickets are swept as new ones go in. Workers KV is no place for
|
|
61
|
+
* tickets: it is eventually consistent, so a ticket read in two places could be used twice.
|
|
62
|
+
*/
|
|
63
|
+
export function d1TicketStore(
|
|
64
|
+
db: D1Like,
|
|
65
|
+
options: { readonly table?: string; readonly now?: () => number } = {},
|
|
66
|
+
): TicketStore {
|
|
67
|
+
const table = options.table ?? "porthole_tickets";
|
|
68
|
+
if (!TABLE.test(table)) {
|
|
69
|
+
throw new TypeError("A ticket table is named with a-z 0-9 _");
|
|
70
|
+
}
|
|
71
|
+
const now = options.now ?? Date.now;
|
|
72
|
+
return {
|
|
73
|
+
async put(id, record) {
|
|
74
|
+
await db.prepare(`DELETE FROM ${table} WHERE expires_at <= ?`).bind(now()).run();
|
|
75
|
+
await db
|
|
76
|
+
.prepare(`INSERT INTO ${table} (id, record, expires_at) VALUES (?, ?, ?)`)
|
|
77
|
+
.bind(id, JSON.stringify(record), record.expiresAt)
|
|
78
|
+
.run();
|
|
79
|
+
},
|
|
80
|
+
async take(id) {
|
|
81
|
+
const row = await db
|
|
82
|
+
.prepare(`DELETE FROM ${table} WHERE id = ? RETURNING record, expires_at`)
|
|
83
|
+
.bind(id)
|
|
84
|
+
.first<{ record: string; expires_at: number }>();
|
|
85
|
+
if (row === null || row.expires_at <= now()) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(row.record) as TicketRecord;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
package/src/server/types.ts
CHANGED
|
@@ -11,12 +11,45 @@ export interface ContentDomain {
|
|
|
11
11
|
/** `http` only for `localhost`. */
|
|
12
12
|
readonly scheme: "https" | "http";
|
|
13
13
|
readonly port?: number;
|
|
14
|
+
/**
|
|
15
|
+
* The label under `domain` a storage scope is served on (P047). Default: 24 base32 characters
|
|
16
|
+
* keyed from the scope with the server's secret (stable, not guessable). A host's own (flat
|
|
17
|
+
* names like `a-<id>`) must return one lowercase DNS label, the same for the same scope, a
|
|
18
|
+
* different one for a different scope, and never a `reserved` one.
|
|
19
|
+
*/
|
|
20
|
+
readonly name?: (scope: string) => string;
|
|
21
|
+
/**
|
|
22
|
+
* Labels this server never serves or names (another server's namespace under the same domain,
|
|
23
|
+
* `f` for `<instance>.f.<domain>`, or `www`). Requests for them get null (the host's routes).
|
|
24
|
+
*/
|
|
25
|
+
readonly reserved?: readonly string[];
|
|
14
26
|
}
|
|
15
27
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Who may open an instance, decided by the host before it calls `issue`: a viewer the host
|
|
30
|
+
* knows, or (a public link) nobody in particular. Porthole never interprets either: identity and
|
|
31
|
+
* authorization policy are the host's (P050).
|
|
32
|
+
*/
|
|
33
|
+
export type Grant = (
|
|
34
|
+
| {
|
|
35
|
+
/** The host's id for the viewer: recorded with the ticket, never shown to content. */
|
|
36
|
+
readonly viewer: string;
|
|
37
|
+
readonly anonymous?: never;
|
|
38
|
+
}
|
|
39
|
+
| {
|
|
40
|
+
/**
|
|
41
|
+
* A public link: no viewer (P046). Only for documents (a shell, an artifact version, the
|
|
42
|
+
* sandbox proxy), never for a url-mode app.
|
|
43
|
+
*/
|
|
44
|
+
readonly anonymous: true;
|
|
45
|
+
readonly viewer?: never;
|
|
46
|
+
}
|
|
47
|
+
) & {
|
|
48
|
+
/**
|
|
49
|
+
* The host's own opaque claim, carried in the ticket and the session cookie and handed back to
|
|
50
|
+
* `authorize` on every request (a session id, a share id). At most 512 characters.
|
|
51
|
+
*/
|
|
52
|
+
readonly subject?: string;
|
|
20
53
|
/** How long the ticket is good for, seconds (default 60). */
|
|
21
54
|
readonly ttlSeconds?: number;
|
|
22
55
|
/**
|
|
@@ -24,11 +57,14 @@ export interface Grant {
|
|
|
24
57
|
* url-mode app's every request, rides on.
|
|
25
58
|
*/
|
|
26
59
|
readonly sessionSeconds?: number;
|
|
27
|
-
}
|
|
60
|
+
};
|
|
28
61
|
|
|
29
62
|
export interface TicketRecord {
|
|
30
63
|
readonly instance: InstanceRef;
|
|
64
|
+
/** The grant's viewer; "" for an anonymous grant. */
|
|
31
65
|
readonly viewer: string;
|
|
66
|
+
readonly anonymous?: boolean;
|
|
67
|
+
readonly subject?: string;
|
|
32
68
|
/** Epoch milliseconds. */
|
|
33
69
|
readonly expiresAt: number;
|
|
34
70
|
/** The grant's `sessionSeconds`, if it set one. */
|
|
@@ -50,7 +86,11 @@ export interface ArtifactStore {
|
|
|
50
86
|
/** What a request's cookie lets in: one instance, for one viewer, on one origin, until a time. */
|
|
51
87
|
export interface Access {
|
|
52
88
|
readonly instance: InstanceRef;
|
|
89
|
+
/** The grant's viewer; "" for an anonymous one. */
|
|
53
90
|
readonly viewer: string;
|
|
91
|
+
readonly anonymous?: boolean;
|
|
92
|
+
/** The grant's opaque subject, if it had one. */
|
|
93
|
+
readonly subject?: string;
|
|
54
94
|
/** The content origin the access is for. */
|
|
55
95
|
readonly origin: string;
|
|
56
96
|
/** When the cookie stops being good, epoch milliseconds. */
|
|
@@ -64,6 +104,40 @@ export interface Access {
|
|
|
64
104
|
*/
|
|
65
105
|
export type Upstream = (request: Request, access: Access) => Response | Promise<Response>;
|
|
66
106
|
|
|
107
|
+
/** What `authorize` is asked about: a request the cookie (or ticket) let in. */
|
|
108
|
+
export interface AuthorizeRequest {
|
|
109
|
+
readonly scope: string;
|
|
110
|
+
readonly instance: InstanceRef;
|
|
111
|
+
readonly viewer: string;
|
|
112
|
+
readonly anonymous: boolean;
|
|
113
|
+
readonly subject?: string;
|
|
114
|
+
/** The request itself (its path, method; never its Porthole cookie's value). */
|
|
115
|
+
readonly request: Request;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The host's answer: let it in, or not (with a short reason code for the host's own UI). */
|
|
119
|
+
export type AuthorizeAnswer =
|
|
120
|
+
| { readonly allow: true }
|
|
121
|
+
| {
|
|
122
|
+
readonly allow: false;
|
|
123
|
+
/** A short code (`revoked`, `left-workspace`): lowercase letters, digits and dashes. */
|
|
124
|
+
readonly code?: string;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The host's check on every request its cookie lets in (P050): revocation, membership,
|
|
129
|
+
* liveness. Porthole calls it after the cookie (or ticket) verifies and before anything of the
|
|
130
|
+
* content is served; a deny is a 403 page that tells the host why (`blocked: denied`).
|
|
131
|
+
*/
|
|
132
|
+
export interface Authorizer {
|
|
133
|
+
check(request: AuthorizeRequest): AuthorizeAnswer | Promise<AuthorizeAnswer>;
|
|
134
|
+
/**
|
|
135
|
+
* How long an answer may be reused for the same session and scope, seconds. Default 0: asked
|
|
136
|
+
* on every request. A host opts into a short time for apps with many requests.
|
|
137
|
+
*/
|
|
138
|
+
readonly ttlSeconds?: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
67
141
|
export interface ServerOptions {
|
|
68
142
|
readonly content: ContentDomain;
|
|
69
143
|
/** At least 32 random bytes: keys the labels and signs tickets and cookies. */
|
|
@@ -79,6 +153,8 @@ export interface ServerOptions {
|
|
|
79
153
|
readonly tickets?: TicketStore;
|
|
80
154
|
/** Url mode: where an external instance's requests go once let in. Absent: nothing serves them. */
|
|
81
155
|
readonly upstream?: Upstream;
|
|
156
|
+
/** The host's per-request check (P050). Absent: a good cookie is enough until it ends. */
|
|
157
|
+
readonly authorize?: Authorizer;
|
|
82
158
|
}
|
|
83
159
|
|
|
84
160
|
export interface PortholeServer {
|
|
@@ -95,8 +171,9 @@ export interface PortholeServer {
|
|
|
95
171
|
*/
|
|
96
172
|
issue(instance: InstanceRef, grant: Grant): Promise<FrameLocation>;
|
|
97
173
|
/**
|
|
98
|
-
* The access a request's cookie carries for a url-mode app on the request's origin, or null
|
|
99
|
-
* for a host's own WebSocket upgrade path,
|
|
174
|
+
* The access a request's cookie carries for a url-mode app on the request's origin, or null
|
|
175
|
+
* (no good cookie, or the host's check denies it): for a host's own WebSocket upgrade path,
|
|
176
|
+
* which a fetch-style handler can't take.
|
|
100
177
|
*/
|
|
101
178
|
access(request: Request): Promise<Access | null>;
|
|
102
179
|
/** The frame source every content origin matches (`https://*.example-usercontent.com`). */
|
package/src/shell/document.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import { hostOriginProblem } from "../isolation/served.ts";
|
|
1
2
|
import { SHELL_RUNTIME } from "./runtime.ts";
|
|
2
3
|
import type { ShellPlan } from "./plan.ts";
|
|
3
4
|
|
|
4
5
|
export interface ShellDocumentOptions {
|
|
5
6
|
/**
|
|
6
7
|
* The origins the shell takes messages from: the embedding page's own (`location.origin`), or
|
|
7
|
-
* every host origin a served document may be embedded by. Exact origins
|
|
8
|
-
* sender must also be the
|
|
8
|
+
* every host origin a served document may be embedded by. Exact origins of any scheme (a
|
|
9
|
+
* desktop app's own, `ferry://app`), no wildcards, never "null". The sender must also be the
|
|
10
|
+
* frame's parent.
|
|
9
11
|
*/
|
|
10
12
|
readonly origins: readonly string[];
|
|
11
13
|
/** The frame document's title (for assistive technology inside the frame). */
|
|
@@ -24,8 +26,6 @@ export interface ShellDocumentOptions {
|
|
|
24
26
|
readonly cspMeta?: boolean;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
const EXACT_ORIGIN = /^(https?:\/\/[a-z0-9.-]+(:\d{1,5})?|https?:\/\/\[::1\](:\d{1,5})?)$/u;
|
|
28
|
-
|
|
29
29
|
const escapeAttribute = (value: string): string =>
|
|
30
30
|
value.replace(/&/gu, "&").replace(/"/gu, """).replace(/</gu, "<");
|
|
31
31
|
|
|
@@ -80,8 +80,9 @@ body {
|
|
|
80
80
|
*/
|
|
81
81
|
export function shellDocument(plan: ShellPlan, csp: string, options: ShellDocumentOptions): string {
|
|
82
82
|
for (const origin of options.origins) {
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
const problem = hostOriginProblem(origin);
|
|
84
|
+
if (problem !== null) {
|
|
85
|
+
throw new TypeError(`shellDocument: ${problem}`);
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
88
|
const runtime = SHELL_RUNTIME.replace("__PLAN__", () =>
|