Repository navigation
Expand file tree
/
Copy pathauthorisation.ts
More file actions
87 lines (81 loc) · 3.48 KB
/
Copy pathauthorisation.ts
File metadata and controls
87 lines (81 loc) · 3.48 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/**
* Who may speak to a computer at all.
*
* This check is the boundary in front of the browser process. Policy, audit and sign-in live in the
* API server and are not on the direct computer port.
*
* It lives here rather than in `index.ts` because that file imports Playwright at module scope, so
* anything in it needs Chrome merely to be imported by a test. The same reasoning moved the control
* state machine out: if a decision matters, it does not belong next to `chromium.launch()`.
*/
/**
* Does this secret match?
*
* Constant-time comparison prevents prefix timing leaks. Length still leaks, but not token content.
*/
export function matchesToken(expected: string, offered: string): boolean {
if (expected.length === 0) return false;
if (offered.length !== expected.length) return false;
let difference = 0;
for (let index = 0; index < offered.length; index += 1) {
difference |= offered.charCodeAt(index) ^ expected.charCodeAt(index);
}
return difference === 0;
}
/**
* The secret a caller offered, however it could carry it.
*
* WebSocket clients cannot set upgrade headers, so the stream also accepts the token as a query
* parameter.
*/
export function offeredToken(headers: Headers, url: URL): string {
// The header path trims; the query path must too, or `?token=%20SECRET` 401s while the same
// value in a header succeeds and the failure looks stream-specific.
if (url.pathname === "/stream")
return url.searchParams.get("token")?.trim() ?? "";
const header = headers.get("x-openbot-computer-token")?.trim();
if (header) return header;
const authorization = headers.get("authorization")?.trim() ?? "";
// The remainder needs trimming too: `Bearer SECRET ` left leading spaces behind.
return authorization.replace(/^Bearer /i, "").trim();
}
/**
* Health is the one thing an unauthenticated caller may ask.
*
* An orchestrator has to be able to check whether this process is up without holding a secret, and
* the answer names no Bot, touches no browser and reveals nothing about what is running.
*/
export function isOpenPath(pathname: string): boolean {
return pathname === "/health";
}
/**
* Which paths act on the computer, and so are refused while a person holds the wheel.
*
* One list, asked once per request, rather than a check inside each handler. The shell is the reason:
* `/exec` arrived after the wheel existed and was never given the guard the page paths had, so a Bot
* could keep running commands and writing files underneath somebody who had taken the browser at a
* login wall. A per-handler check is exactly the thing the next endpoint forgets, which is how that
* happened; a list the dispatcher consults is one an endpoint has to be added to.
*
* Reading is not acting. `/files/read` and `/files/list` stay open so a Bot that has been stopped can
* still read its own notes and explain what it was doing, which is the answer the person handing the
* wheel back usually wants.
*/
const ACTING_PATHS = new Set([
"/navigate",
"/click",
"/type",
"/key",
"/scroll",
"/exec",
"/files/write",
// Typing a person's login into the page. Refused while they hold the wheel, like any other typing.
"/sign-in/fill",
]);
export function actsOnTheComputer(pathname: string): boolean {
return ACTING_PATHS.has(pathname);
}
/** Only page mutations need a fresh browser snapshot after handback. */
export function mutatesBrowser(pathname: string): boolean {
return ["/navigate", "/click", "/type", "/key", "/scroll"].includes(pathname);
}