@botiverse/porthole 0.1.8 → 0.2.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/conformance/clipboard.js +5 -2
- package/dist/conformance/entry.d.ts +37 -0
- package/dist/conformance/entry.js +120 -0
- package/dist/conformance/harness/page.d.ts +32 -0
- package/dist/conformance/harness/page.js +215 -0
- package/dist/conformance/harness.js +1662 -0
- package/dist/conformance/host-kit.d.ts +85 -0
- package/dist/conformance/host-kit.js +341 -0
- package/dist/conformance/index.d.ts +2 -0
- package/dist/conformance/index.js +4 -0
- package/dist/conformance/served.js +13 -3
- package/dist/host/locator.d.ts +39 -0
- package/dist/host/locator.js +72 -0
- package/dist/host/outcome.js +1 -0
- package/dist/host/types.d.ts +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/node/index.d.ts +41 -0
- package/dist/node/index.js +160 -0
- package/dist/platform/web.js +12 -1
- package/dist/server/domain.d.ts +43 -0
- package/dist/server/domain.js +100 -0
- package/dist/server/host.d.ts +158 -0
- package/dist/server/host.js +416 -0
- package/dist/server/index.d.ts +9 -2
- package/dist/server/index.js +7 -0
- package/dist/server/naming.d.ts +12 -1
- package/dist/server/naming.js +9 -8
- package/dist/server/pages.d.ts +9 -0
- package/dist/server/pages.js +32 -0
- package/dist/server/server.js +287 -50
- package/dist/server/types.d.ts +37 -0
- package/dist/transport/post-message.d.ts +9 -1
- package/dist/transport/post-message.js +11 -2
- package/package.json +6 -2
- package/src/conformance/clipboard.ts +5 -2
- package/src/conformance/entry.ts +206 -0
- package/src/conformance/harness/page.ts +267 -0
- package/src/conformance/host-kit.ts +466 -0
- package/src/conformance/index.ts +12 -0
- package/src/conformance/served.ts +14 -3
- package/src/host/locator.ts +111 -0
- package/src/host/outcome.ts +1 -0
- package/src/host/types.ts +2 -0
- package/src/index.ts +7 -0
- package/src/node/index.ts +221 -0
- package/src/platform/web.ts +19 -1
- package/src/server/domain.ts +158 -0
- package/src/server/host.ts +706 -0
- package/src/server/index.ts +27 -1
- package/src/server/naming.ts +22 -2
- package/src/server/pages.ts +44 -0
- package/src/server/server.ts +355 -63
- package/src/server/types.ts +33 -0
- package/src/transport/post-message.ts +12 -1
package/src/server/index.ts
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
*
|
|
10
10
|
* and calls `issue` from its own authenticated API when a viewer opens served content.
|
|
11
11
|
*
|
|
12
|
+
* The host kit (P066): `createContentHost` wraps `createServer` with the routing middleware
|
|
13
|
+
* (`fetch`), the issue endpoint (`issue`), the content domain's parsing and clash guards
|
|
14
|
+
* (`contentDomain`), and an optional health gate with an opaque fallback. An app writes its four
|
|
15
|
+
* policy callbacks and its settings; on Node, `@botiverse/porthole/node` adapts it.
|
|
16
|
+
*
|
|
12
17
|
* Runs on any runtime with web-standard `Request`/`Response` and `crypto.getRandomValues`: Node,
|
|
13
18
|
* Cloudflare Workers (no `nodejs_compat` needed), Deno, Bun (P044). On Workers, keep tickets in D1
|
|
14
19
|
* (`d1TicketStore`, P045).
|
|
@@ -28,6 +33,26 @@
|
|
|
28
33
|
* content without a valid ticket or cookie gets nothing of the content.
|
|
29
34
|
*/
|
|
30
35
|
export { createServer } from "./server.ts";
|
|
36
|
+
export {
|
|
37
|
+
createContentHost,
|
|
38
|
+
HEALTH_PATH,
|
|
39
|
+
type ContentHealthView,
|
|
40
|
+
type ContentHost,
|
|
41
|
+
type ContentHostEvent,
|
|
42
|
+
type ContentHostOptions,
|
|
43
|
+
type EntryOptions,
|
|
44
|
+
type ContentMeta,
|
|
45
|
+
type HealthOptions,
|
|
46
|
+
type HealthProbe,
|
|
47
|
+
} from "./host.ts";
|
|
48
|
+
export {
|
|
49
|
+
clashOf,
|
|
50
|
+
contentDomain,
|
|
51
|
+
ContentDomainError,
|
|
52
|
+
hostnameOf,
|
|
53
|
+
type ContentDomainInfo,
|
|
54
|
+
type ContentDomainProblem,
|
|
55
|
+
} from "./domain.ts";
|
|
31
56
|
export {
|
|
32
57
|
d1TicketSchema,
|
|
33
58
|
d1TicketStore,
|
|
@@ -36,7 +61,7 @@ export {
|
|
|
36
61
|
type D1StatementLike,
|
|
37
62
|
} from "./tickets.ts";
|
|
38
63
|
export { FRAME_STATUS_METHOD } from "../isolation/types.ts";
|
|
39
|
-
export { isHostLabel, prefixedName } from "./naming.ts";
|
|
64
|
+
export { isHostLabel, prefixedName, type Naming } from "./naming.ts";
|
|
40
65
|
export { proxyTo, type ProxyOptions } from "./proxy.ts";
|
|
41
66
|
export type {
|
|
42
67
|
Access,
|
|
@@ -45,6 +70,7 @@ export type {
|
|
|
45
70
|
AuthorizeRequest,
|
|
46
71
|
Authorizer,
|
|
47
72
|
ContentDomain,
|
|
73
|
+
EntryState,
|
|
48
74
|
Grant,
|
|
49
75
|
PortholeServer,
|
|
50
76
|
ServerOptions,
|
package/src/server/naming.ts
CHANGED
|
@@ -24,13 +24,25 @@ const ID = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/u;
|
|
|
24
24
|
* already be label-safe (lowercase letters, digits, inner hyphens); anything else throws rather
|
|
25
25
|
* than being folded into another scope's name.
|
|
26
26
|
*/
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* How scopes are named (`ContentDomain.name`). A naming that can say which scope a label names
|
|
29
|
+
* (`scopeOf`) gives every scope clean addresses a redirect entry can open (P068).
|
|
30
|
+
*/
|
|
31
|
+
export interface Naming {
|
|
32
|
+
(scope: string): string;
|
|
33
|
+
/** The scope a label names, or null for a label this naming never makes. */
|
|
34
|
+
readonly scopeOf?: (label: string) => string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function prefixedName(prefix: string): Naming & {
|
|
38
|
+
readonly scopeOf: (label: string) => string | null;
|
|
39
|
+
} {
|
|
28
40
|
if (!PREFIX.test(prefix)) {
|
|
29
41
|
throw new TypeError(
|
|
30
42
|
"A name prefix is a lowercase letter, then letters or digits, then '-' (a-)",
|
|
31
43
|
);
|
|
32
44
|
}
|
|
33
|
-
|
|
45
|
+
const name = (scope: string): string => {
|
|
34
46
|
const label = `${prefix}${scope}`;
|
|
35
47
|
if (!ID.test(scope) || !isHostLabel(label)) {
|
|
36
48
|
throw new TypeError(
|
|
@@ -39,6 +51,14 @@ export function prefixedName(prefix: string): (scope: string) => string {
|
|
|
39
51
|
}
|
|
40
52
|
return label;
|
|
41
53
|
};
|
|
54
|
+
const scopeOf = (label: string): string | null => {
|
|
55
|
+
if (!label.startsWith(prefix)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const scope = label.slice(prefix.length);
|
|
59
|
+
return ID.test(scope) && isHostLabel(label) ? scope : null;
|
|
60
|
+
};
|
|
61
|
+
return Object.assign(name, { scopeOf });
|
|
42
62
|
}
|
|
43
63
|
|
|
44
64
|
const PREFIX_ENTRY = /^[a-z0-9]([a-z0-9-]{0,61})?\*$/u;
|
package/src/server/pages.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { FRAME_STATUS_METHOD } from "../isolation/types.ts";
|
|
2
2
|
import type { FrameBlock } from "../host/types.ts";
|
|
3
|
+
import { base64url, sha256, utf8 } from "./crypto.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The server's own pages on a content origin: why it shows nothing of the content, and the page a
|
|
@@ -126,3 +127,46 @@ export function enterPage(
|
|
|
126
127
|
headers.set("set-cookie", cookie);
|
|
127
128
|
return new Response(body, { status: 200, headers });
|
|
128
129
|
}
|
|
130
|
+
|
|
131
|
+
/** The status script's hash, for a page that carries it inline (`script-src 'sha256-…'`). */
|
|
132
|
+
let statusHash: string | null = null;
|
|
133
|
+
const hashOfStatus = (): string => {
|
|
134
|
+
statusHash ??= base64url(sha256(utf8(STATUS_SCRIPT)))
|
|
135
|
+
.replace(/-/gu, "+")
|
|
136
|
+
.replace(/_/gu, "/");
|
|
137
|
+
const padding = (4 - (statusHash.length % 4)) % 4;
|
|
138
|
+
return `'sha256-${statusHash}${"=".repeat(padding)}'`;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A redirect entry's page on the app's own origin (P068): why it shows nothing, told to the page
|
|
143
|
+
* that frames it like a content origin's page. Plain, no app chrome; its one script inline under
|
|
144
|
+
* its hash (the app origin serves no script of Porthole's).
|
|
145
|
+
*/
|
|
146
|
+
export function appStatusPage(
|
|
147
|
+
status: number,
|
|
148
|
+
title: string,
|
|
149
|
+
detail: string,
|
|
150
|
+
frameAncestors: string,
|
|
151
|
+
reason: FrameBlock,
|
|
152
|
+
hostOrigins: readonly string[],
|
|
153
|
+
link?: { readonly href: string; readonly text: string },
|
|
154
|
+
method = "GET",
|
|
155
|
+
): Response {
|
|
156
|
+
const more =
|
|
157
|
+
link === undefined
|
|
158
|
+
? ""
|
|
159
|
+
: `<p><a href="${escape(link.href)}" target="_top" rel="noopener">${escape(link.text)}</a></p>`;
|
|
160
|
+
const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${STYLE}a{color:inherit}</style></head><body><main data-porthole-status="${status}" data-porthole-reason="${escape(reason)}" data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>${escape(title)}</h1><p>${escape(detail)}</p>${more}</main><script>${STATUS_SCRIPT}</script></body></html>`;
|
|
161
|
+
return new Response(method === "HEAD" ? null : body, {
|
|
162
|
+
status,
|
|
163
|
+
headers: {
|
|
164
|
+
"content-type": "text/html; charset=utf-8",
|
|
165
|
+
"cache-control": "no-store",
|
|
166
|
+
"content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; script-src ${hashOfStatus()}; base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`,
|
|
167
|
+
"referrer-policy": "no-referrer",
|
|
168
|
+
"x-content-type-options": "nosniff",
|
|
169
|
+
"x-robots-tag": "noindex, nofollow",
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
package/src/server/server.ts
CHANGED
|
@@ -8,7 +8,8 @@ import {
|
|
|
8
8
|
} from "../isolation/served.ts";
|
|
9
9
|
import type { FrameLocation, InstanceRef, ServedDocument } from "../isolation/types.ts";
|
|
10
10
|
import { sandboxProxyDocument } from "../mcp/proxy.ts";
|
|
11
|
-
import { createKeys, newTicket, ticketId } from "./keys.ts";
|
|
11
|
+
import { base32, createKeys, newTicket, ticketId } from "./keys.ts";
|
|
12
|
+
import { randomBytes } from "./crypto.ts";
|
|
12
13
|
import { isHostLabel, reservedLabels } from "./naming.ts";
|
|
13
14
|
import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.ts";
|
|
14
15
|
import type { FrameBlock } from "../host/types.ts";
|
|
@@ -16,6 +17,8 @@ import { memoryTicketStore } from "./tickets.ts";
|
|
|
16
17
|
import type {
|
|
17
18
|
Access,
|
|
18
19
|
AuthorizeAnswer,
|
|
20
|
+
EntryState,
|
|
21
|
+
Grant,
|
|
19
22
|
PortholeServer,
|
|
20
23
|
ServerOptions,
|
|
21
24
|
TicketRecord,
|
|
@@ -31,6 +34,15 @@ const PREFIX = "/__porthole/";
|
|
|
31
34
|
const DOCUMENT_PATH = /^\/__porthole\/d\/([a-z2-7]{16})$/u;
|
|
32
35
|
/** Where a url-mode app's ticket is taken, before the app's own landing path. */
|
|
33
36
|
const ENTER_PATH = "/__porthole/enter";
|
|
37
|
+
/** Where a redirect entry's code is redeemed, before its clean address (P068). */
|
|
38
|
+
const ENTRY_PATH = "/__porthole/entry";
|
|
39
|
+
/** How long a redirect entry may take (the person may sign in on the way), and its code. */
|
|
40
|
+
const ENTRY_STATE_MS = 5 * 60_000;
|
|
41
|
+
const ENTRY_CODE_SECONDS = 60;
|
|
42
|
+
/** How long an entered path is remembered, so an entry that kept no cookie doesn't loop. */
|
|
43
|
+
const ENTERED_SECONDS = 60;
|
|
44
|
+
const NONCE = /^[a-z2-7]{16}$/u;
|
|
45
|
+
const MAX_PATH = 2_048;
|
|
34
46
|
|
|
35
47
|
const LABEL = /^[a-z2-7]{24}$/u;
|
|
36
48
|
/** A host's opaque subject and reason codes. */
|
|
@@ -68,6 +80,8 @@ interface Session {
|
|
|
68
80
|
readonly x: number;
|
|
69
81
|
readonly a?: 1;
|
|
70
82
|
readonly s?: string;
|
|
83
|
+
/** The clean path a document was entered at (P068): it is served there by this cookie. */
|
|
84
|
+
readonly p?: string;
|
|
71
85
|
}
|
|
72
86
|
|
|
73
87
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -263,6 +277,19 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
263
277
|
}
|
|
264
278
|
}
|
|
265
279
|
const frameAncestors = options.hostOrigins.join(" ");
|
|
280
|
+
let authorizeUrl: URL | null = null;
|
|
281
|
+
if (options.entry !== undefined) {
|
|
282
|
+
try {
|
|
283
|
+
authorizeUrl = new URL(options.entry.authorizeUrl);
|
|
284
|
+
} catch {
|
|
285
|
+
throw new TypeError("entry.authorizeUrl must be an absolute URL");
|
|
286
|
+
}
|
|
287
|
+
if (!options.hostOrigins.includes(authorizeUrl.origin) || authorizeUrl.hash !== "") {
|
|
288
|
+
throw new TypeError(
|
|
289
|
+
`entry.authorizeUrl must be on one of the host origins (${options.hostOrigins.join(", ")}), without a fragment`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
266
293
|
|
|
267
294
|
const compiled = new Map<string, CompiledConfig>();
|
|
268
295
|
for (const [name, config] of Object.entries(options.configs)) {
|
|
@@ -435,7 +462,7 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
435
462
|
* page that frames it; on a tab of its own, `Lax`. (Engines that keep no cookie in a cross-site
|
|
436
463
|
* frame, measured: WebKit, still get the document, which comes with the ticket's answer.)
|
|
437
464
|
*/
|
|
438
|
-
function sessionCookie(request: Request, record: TicketRecord): string {
|
|
465
|
+
function sessionCookie(request: Request, record: TicketRecord, path?: string): string {
|
|
439
466
|
const sessionSeconds = record.sessionSeconds ?? DEFAULT_SESSION_SECONDS;
|
|
440
467
|
const session: Session = {
|
|
441
468
|
i: record.instance,
|
|
@@ -443,19 +470,30 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
443
470
|
x: Date.now() + sessionSeconds * 1_000,
|
|
444
471
|
...(record.anonymous === true ? { a: 1 as const } : {}),
|
|
445
472
|
...(record.subject === undefined ? {} : { s: record.subject }),
|
|
473
|
+
...(path === undefined ? {} : { p: path }),
|
|
446
474
|
};
|
|
447
|
-
const framed = request.headers.get("sec-fetch-dest") !== "document";
|
|
448
475
|
return [
|
|
449
476
|
`${cookieName(keys.instanceKey(record.instance))}=${keys.sign(session)}`,
|
|
450
477
|
"Path=/",
|
|
451
478
|
"HttpOnly",
|
|
452
479
|
`Max-Age=${sessionSeconds}`,
|
|
453
|
-
...(
|
|
454
|
-
? ["Secure", "SameSite=None", "Partitioned"]
|
|
455
|
-
: [...(secure ? ["Secure"] : []), "SameSite=Lax"]),
|
|
480
|
+
...cookieContext(request),
|
|
456
481
|
].join("; ");
|
|
457
482
|
}
|
|
458
483
|
|
|
484
|
+
/** A cookie's context: a third party's in a frame (partitioned), first-party `Lax` on a tab. */
|
|
485
|
+
function cookieContext(request: Request): string[] {
|
|
486
|
+
const framed = request.headers.get("sec-fetch-dest") !== "document";
|
|
487
|
+
return framed
|
|
488
|
+
? ["Secure", "SameSite=None", "Partitioned"]
|
|
489
|
+
: [...(secure ? ["Secure"] : []), "SameSite=Lax"];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** The cookie a redirect entry's browser holds while it is away (P068), by its nonce. */
|
|
493
|
+
const nonceCookie = (nonce: string): string => cookieName(`n-${nonce}`);
|
|
494
|
+
/** What an entered path's nonce cookie says after its code was redeemed: the loop guard. */
|
|
495
|
+
const enteredMark = (pathname: string): string => `e.${keys.instanceKey({ entered: pathname })}`;
|
|
496
|
+
|
|
459
497
|
/** The access a request's own cookies give on `label`'s origin, for `key`'s instance or the app. */
|
|
460
498
|
function accessOf(request: Request, label: string, key: string | null): Access | null {
|
|
461
499
|
let best: Access | null = null;
|
|
@@ -505,6 +543,47 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
505
543
|
return best;
|
|
506
544
|
}
|
|
507
545
|
|
|
546
|
+
/** The access a request's cookies give to the document entered at `pathname` (P068). */
|
|
547
|
+
function accessAtPath(request: Request, label: string, pathname: string): Access | null {
|
|
548
|
+
let best: Access | null = null;
|
|
549
|
+
for (const [name, value] of cookiesOf(request)) {
|
|
550
|
+
if (!ourCookie(name)) {
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
const session = keys.verify(value);
|
|
554
|
+
if (
|
|
555
|
+
!isRecord(session) ||
|
|
556
|
+
session.p !== pathname ||
|
|
557
|
+
typeof session.w !== "string" ||
|
|
558
|
+
typeof session.x !== "number" ||
|
|
559
|
+
session.x <= Date.now() ||
|
|
560
|
+
(session.s !== undefined && typeof session.s !== "string")
|
|
561
|
+
) {
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
let instance: InstanceRef;
|
|
565
|
+
try {
|
|
566
|
+
instance = cleanInstance(session.i);
|
|
567
|
+
if (instance.document.kind === "external" || nameOf(instance.scope) !== label) {
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
} catch {
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (best === null || session.x > best.expiresAt) {
|
|
574
|
+
best = {
|
|
575
|
+
instance,
|
|
576
|
+
viewer: session.w,
|
|
577
|
+
origin: originOfLabel(label),
|
|
578
|
+
expiresAt: session.x,
|
|
579
|
+
...(session.a === 1 ? { anonymous: true } : {}),
|
|
580
|
+
...(typeof session.s === "string" ? { subject: session.s } : {}),
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return best;
|
|
585
|
+
}
|
|
586
|
+
|
|
508
587
|
/** A taken ticket, if it is good for `label`'s origin. */
|
|
509
588
|
async function take(ticket: string | null, label: string): Promise<TicketRecord | null> {
|
|
510
589
|
if (ticket === null || ticket.length === 0 || ticket.length > 128) {
|
|
@@ -573,6 +652,16 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
573
652
|
"session",
|
|
574
653
|
);
|
|
575
654
|
}
|
|
655
|
+
return serveAccess(request, access, cookie);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** The document an access opens, once the host's check lets it in. */
|
|
659
|
+
async function serveAccess(
|
|
660
|
+
request: Request,
|
|
661
|
+
access: Access,
|
|
662
|
+
cookie: string | null,
|
|
663
|
+
): Promise<Response> {
|
|
664
|
+
const method = request.method.toUpperCase();
|
|
576
665
|
const refusal = await denied(request, access);
|
|
577
666
|
if (refusal !== null) {
|
|
578
667
|
return refusal;
|
|
@@ -588,6 +677,135 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
588
677
|
return new Response(method === "HEAD" ? null : served.html, { status: 200, headers });
|
|
589
678
|
}
|
|
590
679
|
|
|
680
|
+
/**
|
|
681
|
+
* A clean address without a cookie for it (P068): off to the host's authorize route with a
|
|
682
|
+
* signed state, the browser holding the state's nonce in a cookie of this origin. A path just
|
|
683
|
+
* entered whose cookie still didn't come back is a browser that keeps none here: no loop.
|
|
684
|
+
*/
|
|
685
|
+
function beginEntry(request: Request, url: URL, label: string, to: URL): Response {
|
|
686
|
+
const method = request.method.toUpperCase();
|
|
687
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
688
|
+
return page(
|
|
689
|
+
401,
|
|
690
|
+
"Open this from the app",
|
|
691
|
+
"Open this address in a browser.",
|
|
692
|
+
method,
|
|
693
|
+
"session",
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
const path = `${url.pathname}${url.search}`;
|
|
697
|
+
if (path.length > MAX_PATH) {
|
|
698
|
+
return page(414, "Address too long", "This address is too long to open.", method);
|
|
699
|
+
}
|
|
700
|
+
const mark = enteredMark(url.pathname);
|
|
701
|
+
if (cookiesOf(request).some(([name, value]) => ourCookie(name) && value === mark)) {
|
|
702
|
+
return page(
|
|
703
|
+
401,
|
|
704
|
+
"Open this in a new tab",
|
|
705
|
+
"This browser keeps no sign-in for it inside a frame.",
|
|
706
|
+
method,
|
|
707
|
+
"cookies",
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
const nonce = base32(randomBytes(10));
|
|
711
|
+
const framed = request.headers.get("sec-fetch-dest") !== "document";
|
|
712
|
+
const state = keys.sign({
|
|
713
|
+
e: 1,
|
|
714
|
+
l: label,
|
|
715
|
+
p: path,
|
|
716
|
+
n: nonce,
|
|
717
|
+
x: Date.now() + ENTRY_STATE_MS,
|
|
718
|
+
f: framed ? 1 : 0,
|
|
719
|
+
});
|
|
720
|
+
const location = new URL(to);
|
|
721
|
+
location.searchParams.set("state", state);
|
|
722
|
+
return new Response(null, {
|
|
723
|
+
status: 302,
|
|
724
|
+
headers: {
|
|
725
|
+
location: location.href,
|
|
726
|
+
"set-cookie": [
|
|
727
|
+
`${nonceCookie(nonce)}=1`,
|
|
728
|
+
"Path=/",
|
|
729
|
+
"HttpOnly",
|
|
730
|
+
`Max-Age=${ENTRY_STATE_MS / 1_000}`,
|
|
731
|
+
...cookieContext(request),
|
|
732
|
+
].join("; "),
|
|
733
|
+
"cache-control": "no-store",
|
|
734
|
+
"referrer-policy": "no-referrer",
|
|
735
|
+
"x-robots-tag": "noindex, nofollow",
|
|
736
|
+
},
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Redeems a redirect entry's code (P068): once, on its own label, in the browser that began it
|
|
742
|
+
* (its nonce cookie comes with it). Sets the session and goes to the clean address, the code
|
|
743
|
+
* gone from the address bar and from any referrer.
|
|
744
|
+
*/
|
|
745
|
+
async function redeem(request: Request, url: URL, label: string): Promise<Response> {
|
|
746
|
+
const method = request.method.toUpperCase();
|
|
747
|
+
if (method !== "GET") {
|
|
748
|
+
return page(405, "Not allowed", "Open this link in a browser.", method);
|
|
749
|
+
}
|
|
750
|
+
const record = await take(url.searchParams.get("code"), label);
|
|
751
|
+
if (record?.entry === undefined) {
|
|
752
|
+
return page(
|
|
753
|
+
403,
|
|
754
|
+
"This sign-in link has been used",
|
|
755
|
+
"It works once, for a short time. Open the address again.",
|
|
756
|
+
method,
|
|
757
|
+
"session",
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
const { nonce, path } = record.entry;
|
|
761
|
+
const named = nonceCookie(nonce);
|
|
762
|
+
if (!cookiesOf(request).some(([name, value]) => name === named && value === "1")) {
|
|
763
|
+
return page(
|
|
764
|
+
401,
|
|
765
|
+
"Open this in a new tab",
|
|
766
|
+
"This browser keeps no sign-in for it inside a frame.",
|
|
767
|
+
method,
|
|
768
|
+
"cookies",
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
const origin = originOfLabel(label);
|
|
772
|
+
const target = new URL(path, origin);
|
|
773
|
+
const access: Access = {
|
|
774
|
+
instance: record.instance,
|
|
775
|
+
viewer: record.viewer,
|
|
776
|
+
origin,
|
|
777
|
+
expiresAt: Date.now() + (record.sessionSeconds ?? DEFAULT_SESSION_SECONDS) * 1_000,
|
|
778
|
+
...(record.anonymous === true ? { anonymous: true } : {}),
|
|
779
|
+
...(record.subject === undefined ? {} : { subject: record.subject }),
|
|
780
|
+
};
|
|
781
|
+
const refusal = await denied(request, access);
|
|
782
|
+
if (refusal !== null) {
|
|
783
|
+
return refusal;
|
|
784
|
+
}
|
|
785
|
+
const external = record.instance.document.kind === "external";
|
|
786
|
+
const headers = new Headers({
|
|
787
|
+
location: `${origin}${target.pathname}${target.search}`,
|
|
788
|
+
"cache-control": "no-store",
|
|
789
|
+
"referrer-policy": "no-referrer",
|
|
790
|
+
"x-robots-tag": "noindex, nofollow",
|
|
791
|
+
});
|
|
792
|
+
headers.append(
|
|
793
|
+
"set-cookie",
|
|
794
|
+
sessionCookie(request, record, external ? undefined : target.pathname),
|
|
795
|
+
);
|
|
796
|
+
headers.append(
|
|
797
|
+
"set-cookie",
|
|
798
|
+
[
|
|
799
|
+
`${named}=${enteredMark(target.pathname)}`,
|
|
800
|
+
"Path=/",
|
|
801
|
+
"HttpOnly",
|
|
802
|
+
`Max-Age=${ENTERED_SECONDS}`,
|
|
803
|
+
...cookieContext(request),
|
|
804
|
+
].join("; "),
|
|
805
|
+
);
|
|
806
|
+
return new Response(null, { status: 303, headers });
|
|
807
|
+
}
|
|
808
|
+
|
|
591
809
|
async function enter(request: Request, url: URL, label: string): Promise<Response> {
|
|
592
810
|
const method = request.method.toUpperCase();
|
|
593
811
|
if (method !== "GET") {
|
|
@@ -696,6 +914,68 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
696
914
|
}
|
|
697
915
|
}
|
|
698
916
|
|
|
917
|
+
/** A one-time ticket (or entry code) for an instance the host decided a viewer may see. */
|
|
918
|
+
async function mint(
|
|
919
|
+
instance: InstanceRef,
|
|
920
|
+
grant: Grant,
|
|
921
|
+
entry?: TicketRecord["entry"],
|
|
922
|
+
): Promise<{ readonly clean: InstanceRef; readonly key: string; readonly ticket: string }> {
|
|
923
|
+
const clean = cleanInstance(instance);
|
|
924
|
+
check(clean);
|
|
925
|
+
const anonymous = grant.anonymous === true;
|
|
926
|
+
if (anonymous) {
|
|
927
|
+
if (grant.viewer !== undefined) {
|
|
928
|
+
throw new TypeError("An anonymous grant names no viewer");
|
|
929
|
+
}
|
|
930
|
+
if (clean.document.kind === "external") {
|
|
931
|
+
throw new TypeError("A url-mode app is never opened anonymously (P046)");
|
|
932
|
+
}
|
|
933
|
+
} else if (
|
|
934
|
+
typeof grant.viewer !== "string" ||
|
|
935
|
+
grant.viewer.length === 0 ||
|
|
936
|
+
grant.viewer.length > 512
|
|
937
|
+
) {
|
|
938
|
+
throw new TypeError("A grant's viewer is 1 to 512 characters (or the grant is anonymous)");
|
|
939
|
+
}
|
|
940
|
+
if (
|
|
941
|
+
grant.subject !== undefined &&
|
|
942
|
+
(typeof grant.subject !== "string" || grant.subject.length > SUBJECT_MAX)
|
|
943
|
+
) {
|
|
944
|
+
throw new TypeError(`A grant's subject is a string of at most ${SUBJECT_MAX} characters`);
|
|
945
|
+
}
|
|
946
|
+
const ttl = seconds(grant.ttlSeconds, DEFAULT_TICKET_SECONDS, MAX_TICKET_SECONDS, "ttlSeconds");
|
|
947
|
+
const sessionSeconds = seconds(
|
|
948
|
+
grant.sessionSeconds,
|
|
949
|
+
DEFAULT_SESSION_SECONDS,
|
|
950
|
+
MAX_SESSION_SECONDS,
|
|
951
|
+
"sessionSeconds",
|
|
952
|
+
);
|
|
953
|
+
const key = keys.instanceKey(clean);
|
|
954
|
+
const viewer = anonymous ? "" : grant.viewer;
|
|
955
|
+
const probe = keys.sign({
|
|
956
|
+
i: clean,
|
|
957
|
+
w: viewer,
|
|
958
|
+
x: Number.MAX_SAFE_INTEGER,
|
|
959
|
+
...(anonymous ? { a: 1 } : {}),
|
|
960
|
+
...(grant.subject === undefined ? {} : { s: grant.subject }),
|
|
961
|
+
...(entry === undefined ? {} : { p: entry.path }),
|
|
962
|
+
});
|
|
963
|
+
if (cookieName(key).length + probe.length + 120 > MAX_COOKIE_BYTES) {
|
|
964
|
+
throw new TypeError("This instance is too large to carry in a cookie (fewer origins?)");
|
|
965
|
+
}
|
|
966
|
+
const ticket = newTicket();
|
|
967
|
+
await tickets.put(ticketId(ticket), {
|
|
968
|
+
instance: clean,
|
|
969
|
+
viewer,
|
|
970
|
+
expiresAt: Date.now() + ttl * 1_000,
|
|
971
|
+
...(grant.sessionSeconds === undefined ? {} : { sessionSeconds }),
|
|
972
|
+
...(anonymous ? { anonymous: true } : {}),
|
|
973
|
+
...(grant.subject === undefined ? {} : { subject: grant.subject }),
|
|
974
|
+
...(entry === undefined ? {} : { entry }),
|
|
975
|
+
});
|
|
976
|
+
return { clean, key, ticket };
|
|
977
|
+
}
|
|
978
|
+
|
|
699
979
|
const underDomain = (host: string): boolean => host === domain || host.endsWith(`.${domain}`);
|
|
700
980
|
|
|
701
981
|
/**
|
|
@@ -749,62 +1029,7 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
749
1029
|
originFor: (scope) => originOfLabel(nameOf(scope)),
|
|
750
1030
|
|
|
751
1031
|
async issue(instance, grant): Promise<FrameLocation> {
|
|
752
|
-
const clean =
|
|
753
|
-
check(clean);
|
|
754
|
-
const anonymous = grant.anonymous === true;
|
|
755
|
-
if (anonymous) {
|
|
756
|
-
if (grant.viewer !== undefined) {
|
|
757
|
-
throw new TypeError("An anonymous grant names no viewer");
|
|
758
|
-
}
|
|
759
|
-
if (clean.document.kind === "external") {
|
|
760
|
-
throw new TypeError("A url-mode app is never opened anonymously (P046)");
|
|
761
|
-
}
|
|
762
|
-
} else if (
|
|
763
|
-
typeof grant.viewer !== "string" ||
|
|
764
|
-
grant.viewer.length === 0 ||
|
|
765
|
-
grant.viewer.length > 512
|
|
766
|
-
) {
|
|
767
|
-
throw new TypeError("A grant's viewer is 1 to 512 characters (or the grant is anonymous)");
|
|
768
|
-
}
|
|
769
|
-
if (
|
|
770
|
-
grant.subject !== undefined &&
|
|
771
|
-
(typeof grant.subject !== "string" || grant.subject.length > SUBJECT_MAX)
|
|
772
|
-
) {
|
|
773
|
-
throw new TypeError(`A grant's subject is a string of at most ${SUBJECT_MAX} characters`);
|
|
774
|
-
}
|
|
775
|
-
const ttl = seconds(
|
|
776
|
-
grant.ttlSeconds,
|
|
777
|
-
DEFAULT_TICKET_SECONDS,
|
|
778
|
-
MAX_TICKET_SECONDS,
|
|
779
|
-
"ttlSeconds",
|
|
780
|
-
);
|
|
781
|
-
const sessionSeconds = seconds(
|
|
782
|
-
grant.sessionSeconds,
|
|
783
|
-
DEFAULT_SESSION_SECONDS,
|
|
784
|
-
MAX_SESSION_SECONDS,
|
|
785
|
-
"sessionSeconds",
|
|
786
|
-
);
|
|
787
|
-
const key = keys.instanceKey(clean);
|
|
788
|
-
const viewer = anonymous ? "" : grant.viewer;
|
|
789
|
-
const probe = keys.sign({
|
|
790
|
-
i: clean,
|
|
791
|
-
w: viewer,
|
|
792
|
-
x: Number.MAX_SAFE_INTEGER,
|
|
793
|
-
...(anonymous ? { a: 1 } : {}),
|
|
794
|
-
...(grant.subject === undefined ? {} : { s: grant.subject }),
|
|
795
|
-
});
|
|
796
|
-
if (cookieName(key).length + probe.length + 120 > MAX_COOKIE_BYTES) {
|
|
797
|
-
throw new TypeError("This instance is too large to carry in a cookie (fewer origins?)");
|
|
798
|
-
}
|
|
799
|
-
const ticket = newTicket();
|
|
800
|
-
await tickets.put(ticketId(ticket), {
|
|
801
|
-
instance: clean,
|
|
802
|
-
viewer,
|
|
803
|
-
expiresAt: Date.now() + ttl * 1_000,
|
|
804
|
-
...(grant.sessionSeconds === undefined ? {} : { sessionSeconds }),
|
|
805
|
-
...(anonymous ? { anonymous: true } : {}),
|
|
806
|
-
...(grant.subject === undefined ? {} : { subject: grant.subject }),
|
|
807
|
-
});
|
|
1032
|
+
const { clean, key, ticket } = await mint(instance, grant);
|
|
808
1033
|
const origin = originOfLabel(nameOf(clean.scope));
|
|
809
1034
|
const path =
|
|
810
1035
|
clean.document.kind === "external"
|
|
@@ -813,6 +1038,57 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
813
1038
|
return { url: `${origin}${path}`, origin, reports: true };
|
|
814
1039
|
},
|
|
815
1040
|
|
|
1041
|
+
urlOf(scope, path = "/") {
|
|
1042
|
+
if (!path.startsWith("/") || path.startsWith("//") || path.startsWith(PREFIX)) {
|
|
1043
|
+
throw new TypeError("A clean address's path starts with one / and isn't Porthole's own");
|
|
1044
|
+
}
|
|
1045
|
+
return `${originOfLabel(nameOf(scope))}${path}`;
|
|
1046
|
+
},
|
|
1047
|
+
|
|
1048
|
+
entryState(token) {
|
|
1049
|
+
const state = token.length > 8_192 ? null : keys.verify(token);
|
|
1050
|
+
if (
|
|
1051
|
+
!isRecord(state) ||
|
|
1052
|
+
state.e !== 1 ||
|
|
1053
|
+
typeof state.l !== "string" ||
|
|
1054
|
+
!ours(state.l) ||
|
|
1055
|
+
typeof state.p !== "string" ||
|
|
1056
|
+
!state.p.startsWith("/") ||
|
|
1057
|
+
typeof state.n !== "string" ||
|
|
1058
|
+
!NONCE.test(state.n) ||
|
|
1059
|
+
typeof state.x !== "number" ||
|
|
1060
|
+
(state.f !== 0 && state.f !== 1)
|
|
1061
|
+
) {
|
|
1062
|
+
return null;
|
|
1063
|
+
}
|
|
1064
|
+
return {
|
|
1065
|
+
label: state.l,
|
|
1066
|
+
path: state.p,
|
|
1067
|
+
nonce: state.n,
|
|
1068
|
+
framed: state.f === 1,
|
|
1069
|
+
expired: state.x <= Date.now(),
|
|
1070
|
+
};
|
|
1071
|
+
},
|
|
1072
|
+
|
|
1073
|
+
async enterBy(state: EntryState, instance: InstanceRef, grant: Grant): Promise<string> {
|
|
1074
|
+
if (state.expired) {
|
|
1075
|
+
throw new TypeError("This entry's state has expired: start again from the clean address");
|
|
1076
|
+
}
|
|
1077
|
+
const clean = cleanInstance(instance);
|
|
1078
|
+
if (nameOf(clean.scope) !== state.label) {
|
|
1079
|
+
throw new TypeError("The instance isn't served on the address the entry began at");
|
|
1080
|
+
}
|
|
1081
|
+
const { ticket } = await mint(
|
|
1082
|
+
clean,
|
|
1083
|
+
{ ...grant, ttlSeconds: ENTRY_CODE_SECONDS },
|
|
1084
|
+
{
|
|
1085
|
+
path: state.path,
|
|
1086
|
+
nonce: state.nonce,
|
|
1087
|
+
},
|
|
1088
|
+
);
|
|
1089
|
+
return `${originOfLabel(state.label)}${ENTRY_PATH}?code=${ticket}`;
|
|
1090
|
+
},
|
|
1091
|
+
|
|
816
1092
|
async access(request) {
|
|
817
1093
|
const at = labelOf(request);
|
|
818
1094
|
if (at === null || at === "mismatch" || !ours(at.label)) {
|
|
@@ -844,6 +1120,9 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
844
1120
|
if (url.pathname === ENTER_PATH) {
|
|
845
1121
|
return await enter(request, url, label);
|
|
846
1122
|
}
|
|
1123
|
+
if (url.pathname === ENTRY_PATH && authorizeUrl !== null) {
|
|
1124
|
+
return await redeem(request, url, label);
|
|
1125
|
+
}
|
|
847
1126
|
const script = pageScript(url.pathname);
|
|
848
1127
|
if (script !== null) {
|
|
849
1128
|
return script;
|
|
@@ -859,8 +1138,21 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
859
1138
|
if (document?.[1] !== undefined) {
|
|
860
1139
|
return await serveDocument(request, url, label, document[1]);
|
|
861
1140
|
}
|
|
1141
|
+
if (url.pathname.startsWith(PREFIX)) {
|
|
1142
|
+
return page(404, "Nothing here", "This address names no content.", method);
|
|
1143
|
+
}
|
|
1144
|
+
// A clean address (P068): the document entered here, else the app, else entry.
|
|
1145
|
+
if (authorizeUrl !== null) {
|
|
1146
|
+
const entered = accessAtPath(request, label, url.pathname);
|
|
1147
|
+
if (entered !== null) {
|
|
1148
|
+
return await serveAccess(request, entered, null);
|
|
1149
|
+
}
|
|
1150
|
+
if (options.upstream === undefined || accessOf(request, label, null) === null) {
|
|
1151
|
+
return beginEntry(request, url, label, authorizeUrl);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
862
1154
|
// Without an upstream, only Porthole's own paths are served here: any other is nothing.
|
|
863
|
-
if (
|
|
1155
|
+
if (options.upstream === undefined) {
|
|
864
1156
|
return page(404, "Nothing here", "This address names no content.", method);
|
|
865
1157
|
}
|
|
866
1158
|
return await serveApp(request, label);
|