@effect-agent/platform-cloudflare 0.1.0-beta.52 → 0.1.0-beta.53
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/Alarm.d.mts +45 -6
- package/dist/Alarm.mjs +111 -47
- package/dist/Alarm.mjs.map +1 -1
- package/dist/CloudflareThreadClient.d.mts +41 -41
- package/dist/ProtectedBrowser.d.mts +5 -3
- package/dist/ProtectedBrowser.mjs +180 -36
- package/dist/ProtectedBrowser.mjs.map +1 -1
- package/dist/{ThreadObject-BY8axaWT.mjs → ThreadObject-OuKI0mBU.mjs} +33 -6
- package/dist/ThreadObject-OuKI0mBU.mjs.map +1 -0
- package/dist/{ThreadObject-BSRVxtPI.d.mts → ThreadObject-d-ui3RZ-.d.mts} +28 -17
- package/dist/ThreadObject.d.mts +2 -2
- package/dist/ThreadObject.mjs +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +1 -1
- package/src/Alarm.ts +242 -72
- package/src/ThreadObject.ts +1 -0
- package/src/internal/layers.ts +93 -9
- package/src/protected-browser/binding.ts +2 -1
- package/src/protected-browser/inspect-frame.ts +62 -19
- package/src/protected-browser/native.ts +50 -5
- package/src/protected-browser/policy.ts +138 -13
- package/dist/ThreadObject-BY8axaWT.mjs.map +0 -1
|
@@ -5,41 +5,66 @@ export const maxAttributeLength = 2048;
|
|
|
5
5
|
export const inspectFrame = `(() => {
|
|
6
6
|
const doc = document;
|
|
7
7
|
const forms = [];
|
|
8
|
-
const
|
|
8
|
+
const isChoice = el => el instanceof HTMLInputElement && ['radio','checkbox'].includes(el.type);
|
|
9
|
+
const presented = el => {
|
|
10
|
+
if (el.closest('[hidden],[aria-hidden="true"],[inert]') ||
|
|
11
|
+
['hidden','collapse'].includes(getComputedStyle(el).visibility)) return false;
|
|
12
|
+
for (let parent = el; parent; parent = parent.parentElement) {
|
|
13
|
+
if (getComputedStyle(parent).opacity === '0') return false;
|
|
14
|
+
}
|
|
15
|
+
return true;
|
|
16
|
+
};
|
|
17
|
+
const hasLayout = el => presented(el) &&
|
|
18
|
+
[...el.getClientRects()].some(rect => rect.width > 0 && rect.height > 0);
|
|
19
|
+
const available = el => el.type !== 'hidden' && !el.closest('[hidden],[aria-hidden="true"],[inert]') &&
|
|
20
|
+
(hasLayout(el) || (isChoice(el) && [...(el.labels ?? [])].some(hasLayout)));
|
|
21
|
+
const elements = [...doc.querySelectorAll('input,textarea,select,button,a[href]')].filter(available).slice(0, 65);
|
|
22
|
+
const labelText = el => {
|
|
23
|
+
const label = el.labels?.[0]?.cloneNode(true);
|
|
24
|
+
label?.querySelectorAll('input,textarea,select,script,style,noscript').forEach(child => child.remove());
|
|
25
|
+
return label?.textContent ?? el.getAttribute('aria-label');
|
|
26
|
+
};
|
|
9
27
|
const describe = (el) => {
|
|
10
|
-
if (doc !== document || !el.isConnected || el.ownerDocument !== doc) return null;
|
|
28
|
+
if (doc !== document || !el.isConnected || el.ownerDocument !== doc || !available(el)) return null;
|
|
11
29
|
const form = el.form ?? null;
|
|
12
30
|
let formIndex = forms.indexOf(form);
|
|
13
31
|
if (formIndex < 0) { formIndex = forms.length; forms.push(form); }
|
|
14
|
-
|
|
32
|
+
// Resolved href (including path/query/fragment and base-URL changes) is part of the fingerprint.
|
|
33
|
+
const action = el instanceof HTMLAnchorElement ? el.href : form ? (el.hasAttribute('formaction') ? el.formAction : form.action || doc.URL) : doc.URL;
|
|
15
34
|
const method = form ? (el.hasAttribute('formmethod') ? el.formMethod : form.method) : '';
|
|
16
35
|
const enctype = form?.enctype ?? '';
|
|
17
36
|
const name = el.name ?? '';
|
|
18
37
|
const completion = el.getAttribute('autocomplete') ?? '';
|
|
19
38
|
const inputType = el.type ?? '';
|
|
39
|
+
const choiceValue = isChoice(el) ? el.value : '';
|
|
20
40
|
// Reject before parsing, fingerprinting or CDP transfer. Truncation could hide a target change.
|
|
21
|
-
if ([action, method, enctype, name, completion, inputType].some(value => value.length > ${maxAttributeLength})) return null;
|
|
41
|
+
if ([action, method, enctype, name, completion, inputType, choiceValue].some(value => value.length > ${maxAttributeLength})) return null;
|
|
22
42
|
const type = inputType.toLowerCase();
|
|
23
43
|
const autocomplete = completion.trim().toLowerCase().split(/\\s+/).at(-1);
|
|
24
44
|
const cardRoles = { 'cc-name':'card-name', 'cc-number':'card-number', 'cc-exp':'card-expiry',
|
|
25
45
|
'cc-exp-month':'card-expiry-month', 'cc-exp-year':'card-expiry-year', 'cc-csc':'card-security-code' };
|
|
26
46
|
let role = 'unsupported';
|
|
27
|
-
const nativeField = el instanceof HTMLInputElement || el instanceof HTMLSelectElement;
|
|
28
|
-
if (nativeField &&
|
|
29
|
-
if (el
|
|
30
|
-
else if (
|
|
31
|
-
if (
|
|
32
|
-
|
|
47
|
+
const nativeField = el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement;
|
|
48
|
+
if (nativeField && !['submit','button'].includes(type) && !el.matches(':disabled') && !el.readOnly && !(el instanceof HTMLSelectElement && el.multiple)) {
|
|
49
|
+
if (isChoice(el)) role = type;
|
|
50
|
+
else if (type === 'password' || ['current-password','new-password','one-time-code'].includes(autocomplete)) {
|
|
51
|
+
if (type === 'password' && form) role = 'password';
|
|
52
|
+
} else if (['text','email','tel','number','month','search','url','date','time','week','datetime-local','textarea',''].includes(type) || el instanceof HTMLSelectElement) {
|
|
53
|
+
if (cardRoles[autocomplete]) { if (form) role = cardRoles[autocomplete]; }
|
|
54
|
+
else if (autocomplete === 'username' || autocomplete === 'email') { if (form) role = 'username'; }
|
|
33
55
|
else if (['text','email'].includes(type) && form && form.querySelector('input[type="password"]')) role = 'username';
|
|
56
|
+
else if (el instanceof HTMLSelectElement) { if (!el.multiple) role = 'select'; }
|
|
57
|
+
else role = 'text';
|
|
34
58
|
}
|
|
35
59
|
} else if (el instanceof HTMLButtonElement || (el instanceof HTMLInputElement && ['submit','button'].includes(type))) {
|
|
36
|
-
if (!el.disabled) role = type === 'submit' && form ? 'submit' : type === 'button' ? 'button' : 'unsupported';
|
|
60
|
+
if (!el.matches(':disabled')) role = type === 'submit' && form ? 'submit' : type === 'button' ? 'button' : 'unsupported';
|
|
37
61
|
} else if (el instanceof HTMLAnchorElement) role = 'link';
|
|
38
|
-
const fingerprint = JSON.stringify([role, action, method, enctype, name, completion, type]);
|
|
39
|
-
return { role, formIndex, action, fingerprint,
|
|
40
|
-
label: (el
|
|
62
|
+
const fingerprint = JSON.stringify([role, action, method, enctype, name, completion, type, choiceValue]);
|
|
63
|
+
return { role, formIndex, action, fingerprint, ...(isChoice(el) ? {checked: el.checked} : {}),
|
|
64
|
+
label: (labelText(el) ?? (nativeField ? '' : el.textContent) ?? '').slice(0,200) };
|
|
41
65
|
};
|
|
42
|
-
const expose = ({role, formIndex, action, label}) =>
|
|
66
|
+
const expose = ({role, formIndex, action, label, checked}) =>
|
|
67
|
+
({role, formIndex, action, label, ...(checked === undefined ? {} : {checked})});
|
|
43
68
|
const original = elements.map(describe);
|
|
44
69
|
const validate = (index) => {
|
|
45
70
|
const current = describe(elements[index]);
|
|
@@ -51,14 +76,32 @@ export const inspectFrame = `(() => {
|
|
|
51
76
|
doc, elements, original: original.map(current => current && expose(current)), validate,
|
|
52
77
|
text: () => {
|
|
53
78
|
if (doc !== document) return null;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
79
|
+
if (!doc.body) return '';
|
|
80
|
+
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
|
|
81
|
+
const range = doc.createRange();
|
|
82
|
+
let text = '';
|
|
83
|
+
for (let node = walker.nextNode(); node && text.length < 65536; node = walker.nextNode()) {
|
|
84
|
+
const parent = node.parentElement;
|
|
85
|
+
if (!parent || parent.closest('input,textarea,select,script,style,noscript,iframe,object,embed')) continue;
|
|
86
|
+
if (!presented(parent)) continue;
|
|
87
|
+
range.selectNodeContents(node);
|
|
88
|
+
if (![...range.getClientRects()].some(rect => rect.width > 0 && rect.height > 0)) continue;
|
|
89
|
+
text += (node.textContent ?? '').replace(/\\s+/g, ' ') + ' ';
|
|
90
|
+
}
|
|
91
|
+
return text.slice(0,65536);
|
|
57
92
|
},
|
|
58
93
|
fill: (index, role, value) => {
|
|
59
94
|
const current = validate(index);
|
|
60
95
|
if (!current || current.role !== role) return false;
|
|
61
96
|
const el = elements[index];
|
|
97
|
+
if (role === 'select') {
|
|
98
|
+
const options = [...el.options].filter(option => !option.matches(':disabled'));
|
|
99
|
+
const values = options.filter(option => option.value === value);
|
|
100
|
+
const matches = values.length > 0 ? values : options.filter(option => option.textContent?.trim() === value);
|
|
101
|
+
if (matches.length !== 1) return 'unsupported';
|
|
102
|
+
value = matches[0].value;
|
|
103
|
+
if ([...el.options].filter(option => option.value === value).length !== 1) return 'unsupported';
|
|
104
|
+
}
|
|
62
105
|
let prototype = Object.getPrototypeOf(el);
|
|
63
106
|
let setter;
|
|
64
107
|
while (prototype && !setter) { setter = Object.getOwnPropertyDescriptor(prototype,'value')?.set; prototype = Object.getPrototypeOf(prototype); }
|
|
@@ -73,7 +116,7 @@ export const inspectFrame = `(() => {
|
|
|
73
116
|
},
|
|
74
117
|
click: (index) => {
|
|
75
118
|
const current = validate(index);
|
|
76
|
-
if (!current || !['button','submit','link'].includes(current.role)) return false;
|
|
119
|
+
if (!current || !['button','submit','link','radio','checkbox'].includes(current.role)) return false;
|
|
77
120
|
if (current.role === 'submit' && elements[index].form && !elements[index].form.matches(':valid')) return 'needs-attention';
|
|
78
121
|
elements[index].click();
|
|
79
122
|
return true;
|
|
@@ -30,6 +30,7 @@ const Description = Schema.Struct({
|
|
|
30
30
|
formIndex: Schema.Natural,
|
|
31
31
|
action: Schema.String.check(Schema.isMaxLength(maxAttributeLength)),
|
|
32
32
|
label: Schema.String.check(Schema.isMaxLength(200)),
|
|
33
|
+
checked: Schema.optionalKey(Schema.Boolean),
|
|
33
34
|
});
|
|
34
35
|
|
|
35
36
|
const Descriptions = Schema.Array(Schema.NullOr(Description)).check(Schema.isMaxLength(65));
|
|
@@ -97,6 +98,7 @@ export const makeProtectedNativeTransport = Effect.fn("ProtectedNativeTransport.
|
|
|
97
98
|
let closed = false;
|
|
98
99
|
let violation = false;
|
|
99
100
|
let documentRef: string | undefined;
|
|
101
|
+
let observationOrigins: ReadonlySet<string> | undefined;
|
|
100
102
|
const frames = new Map<Frame, FrameState>();
|
|
101
103
|
const controls = new Map<string, ControlState>();
|
|
102
104
|
|
|
@@ -144,13 +146,30 @@ export const makeProtectedNativeTransport = Effect.fn("ProtectedNativeTransport.
|
|
|
144
146
|
clear();
|
|
145
147
|
};
|
|
146
148
|
|
|
147
|
-
const
|
|
148
|
-
yield* check;
|
|
149
|
+
const observationFrames = Effect.gen(function* () {
|
|
149
150
|
const list = page.frames();
|
|
150
151
|
|
|
151
|
-
if (list.length >
|
|
152
|
+
if (list.length > 32) return yield* transportError("unsupported");
|
|
153
|
+
|
|
154
|
+
// Opaque/blank child documents have no observation channel or credential targets.
|
|
155
|
+
// The main document and every retained HTTPS child still require strict origin validation.
|
|
156
|
+
return yield* Effect.filter(list, (frame) =>
|
|
157
|
+
frame === page.mainFrame()
|
|
158
|
+
? Effect.succeed(true)
|
|
159
|
+
: Effect.try({
|
|
160
|
+
try: () => frame.url() !== "" && new URL(frame.url()).protocol === "https:",
|
|
161
|
+
catch: () => transportError("provider"),
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const context = Effect.gen(function* () {
|
|
167
|
+
yield* check;
|
|
168
|
+
const list = yield* observationFrames;
|
|
152
169
|
const topOrigin = yield* origin(page.url());
|
|
153
|
-
const frameOrigins = yield* Effect.forEach(list, (frame) => origin(frame.url()));
|
|
170
|
+
const frameOrigins = [...new Set(yield* Effect.forEach(list, (frame) => origin(frame.url())))];
|
|
171
|
+
|
|
172
|
+
if (frameOrigins.length > 16) return yield* transportError("unsupported");
|
|
154
173
|
|
|
155
174
|
if (documentRef === undefined) documentRef = yield* uuid;
|
|
156
175
|
|
|
@@ -175,6 +194,8 @@ export const makeProtectedNativeTransport = Effect.fn("ProtectedNativeTransport.
|
|
|
175
194
|
|
|
176
195
|
if (
|
|
177
196
|
!state ||
|
|
197
|
+
(observationOrigins !== undefined &&
|
|
198
|
+
!observationOrigins.has(state.control.target.frameOrigin)) ||
|
|
178
199
|
state.expires <= (yield* clock.currentTimeMillis) ||
|
|
179
200
|
!(yield* isCurrent(state.frame))
|
|
180
201
|
)
|
|
@@ -232,6 +253,22 @@ export const makeProtectedNativeTransport = Effect.fn("ProtectedNativeTransport.
|
|
|
232
253
|
);
|
|
233
254
|
|
|
234
255
|
return {
|
|
256
|
+
restrictObservation: Effect.fn("ProtectedNativeTransport.restrictObservation")(
|
|
257
|
+
function* (origins) {
|
|
258
|
+
yield* check;
|
|
259
|
+
observationOrigins =
|
|
260
|
+
origins === undefined
|
|
261
|
+
? undefined
|
|
262
|
+
: new Set(yield* decode(Schema.Array(CredentialOrigin), origins));
|
|
263
|
+
// A later grant expansion must not revive previously excluded references.
|
|
264
|
+
for (const [ref, state] of controls)
|
|
265
|
+
if (
|
|
266
|
+
observationOrigins !== undefined &&
|
|
267
|
+
!observationOrigins.has(state.control.target.frameOrigin)
|
|
268
|
+
)
|
|
269
|
+
controls.delete(ref);
|
|
270
|
+
},
|
|
271
|
+
),
|
|
235
272
|
context,
|
|
236
273
|
invalidate,
|
|
237
274
|
close,
|
|
@@ -252,8 +289,13 @@ export const makeProtectedNativeTransport = Effect.fn("ProtectedNativeTransport.
|
|
|
252
289
|
const discovered: Array<ProtectedBrowserControl> = [];
|
|
253
290
|
let text = "";
|
|
254
291
|
let truncated = false;
|
|
292
|
+
const frameOrigins = new Set<string>();
|
|
293
|
+
|
|
294
|
+
for (const frame of yield* observationFrames) {
|
|
295
|
+
const frameOrigin = yield* origin(frame.url());
|
|
255
296
|
|
|
256
|
-
|
|
297
|
+
if (observationOrigins !== undefined && !observationOrigins.has(frameOrigin)) continue;
|
|
298
|
+
frameOrigins.add(frameOrigin);
|
|
257
299
|
if (typeof frame.isolatedRealm !== "function") return yield* transportError("unsupported");
|
|
258
300
|
const handle = yield* remote(() => frame.isolatedRealm().evaluateHandle(inspectFrame));
|
|
259
301
|
|
|
@@ -300,6 +342,8 @@ export const makeProtectedNativeTransport = Effect.fn("ProtectedNativeTransport.
|
|
|
300
342
|
ref: yield* uuid,
|
|
301
343
|
role: desc.role,
|
|
302
344
|
label: desc.label,
|
|
345
|
+
...(desc.checked === undefined ? {} : { checked: desc.checked }),
|
|
346
|
+
...(desc.role === "link" ? { url: desc.action } : {}),
|
|
303
347
|
target: CredentialTarget.make({
|
|
304
348
|
topOrigin: before.topOrigin,
|
|
305
349
|
frameOrigin: yield* origin(frame.url()),
|
|
@@ -338,6 +382,7 @@ export const makeProtectedNativeTransport = Effect.fn("ProtectedNativeTransport.
|
|
|
338
382
|
|
|
339
383
|
return yield* decode(ProtectedDiscovery, {
|
|
340
384
|
...before,
|
|
385
|
+
frameOrigins: [...frameOrigins],
|
|
341
386
|
text,
|
|
342
387
|
controls: discovered,
|
|
343
388
|
truncated,
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
CredentialOffer,
|
|
8
8
|
CredentialOfferMetadata,
|
|
9
9
|
CredentialOrigin,
|
|
10
|
+
CredentialObservationDecision,
|
|
10
11
|
type CredentialTarget,
|
|
11
12
|
CredentialUseResult,
|
|
12
13
|
ListCredentialOffers,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
ProtectedBrowser,
|
|
15
16
|
ProtectedBrowserClick,
|
|
16
17
|
ProtectedBrowserError,
|
|
18
|
+
ProtectedBrowserFill,
|
|
17
19
|
ProtectedBrowserNavigate,
|
|
18
20
|
ProtectedBrowserObservation,
|
|
19
21
|
ProtectedBrowserControl,
|
|
@@ -22,6 +24,7 @@ import {
|
|
|
22
24
|
type CredentialKind,
|
|
23
25
|
type CredentialUseAuthorization,
|
|
24
26
|
type ProtectedBrowserHandle,
|
|
27
|
+
type ProtectedBrowserAction,
|
|
25
28
|
type ProtectedCleanup,
|
|
26
29
|
type ProtectedObservationState,
|
|
27
30
|
} from "@effect-agent/sandbox/ProtectedBrowser";
|
|
@@ -51,6 +54,10 @@ export class ProtectedBrowserDispatch extends Context.Service<
|
|
|
51
54
|
|
|
52
55
|
/** Decoded adapter boundary. SDK exceptions and page diagnostics never cross this port. */
|
|
53
56
|
export interface ProtectedBrowserTransport {
|
|
57
|
+
/** Select observation/target origins; undefined restores all network-permitted HTTPS frames. */
|
|
58
|
+
readonly restrictObservation: (
|
|
59
|
+
origins: ReadonlyArray<typeof CredentialOrigin.Type> | undefined,
|
|
60
|
+
) => Effect.Effect<void, ProtectedTransportError>;
|
|
54
61
|
readonly context: Effect.Effect<typeof ProtectedPageContext.Type, ProtectedTransportError>;
|
|
55
62
|
readonly discover: Effect.Effect<typeof ProtectedDiscovery.Type, ProtectedTransportError>;
|
|
56
63
|
readonly target: (ref: string) => Effect.Effect<ProtectedBrowserControl, ProtectedTransportError>;
|
|
@@ -58,7 +65,7 @@ export interface ProtectedBrowserTransport {
|
|
|
58
65
|
readonly click: (ref: string) => Effect.Effect<void, ProtectedTransportError>;
|
|
59
66
|
readonly fill: (
|
|
60
67
|
ref: string,
|
|
61
|
-
role: typeof CredentialFieldRole.Type,
|
|
68
|
+
role: typeof CredentialFieldRole.Type | "text" | "select",
|
|
62
69
|
value: Redacted.Redacted<string>,
|
|
63
70
|
) => Effect.Effect<void, ProtectedTransportError, ProtectedBrowserDispatch>;
|
|
64
71
|
/** Invalidates local references synchronously before bounded exact-session cleanup. */
|
|
@@ -133,7 +140,7 @@ const secretFor = (material: BrowserCredentialMaterial, role: typeof CredentialF
|
|
|
133
140
|
|
|
134
141
|
/**
|
|
135
142
|
* Fresh private passes only. Account administrators and Browser Rendering token holders are
|
|
136
|
-
* trusted operators. No viewer, handoff, raw JavaScript, screenshot or plaintext
|
|
143
|
+
* trusted operators. No viewer, handoff, raw JavaScript, screenshot or plaintext credential API exists.
|
|
137
144
|
* Hosts explicitly authorize post-exposure observations for recipients they trust not to echo.
|
|
138
145
|
*/
|
|
139
146
|
export const browserRunProtectedLayer = () =>
|
|
@@ -240,24 +247,69 @@ export const browserRunProtectedLayer = () =>
|
|
|
240
247
|
|
|
241
248
|
const permitObservation = Effect.gen(function* () {
|
|
242
249
|
const context = yield* pageContext;
|
|
250
|
+
let origins: ReadonlyArray<typeof CredentialOrigin.Type> | undefined;
|
|
243
251
|
|
|
244
252
|
if (exposures.length > 0) {
|
|
245
253
|
observation = "protected";
|
|
254
|
+
const principal = yield* caller;
|
|
246
255
|
|
|
247
|
-
const
|
|
248
|
-
.observation({ ...context, caller:
|
|
256
|
+
const rawDecision = yield* access
|
|
257
|
+
.observation({ ...context, caller: principal, exposures: [...exposures] })
|
|
249
258
|
.pipe(Effect.mapError((error) => fail(error.reason)));
|
|
250
259
|
|
|
251
|
-
if (
|
|
252
|
-
return yield* fail("
|
|
260
|
+
if (Redacted.value(yield* caller) !== Redacted.value(principal))
|
|
261
|
+
return yield* fail("denied");
|
|
262
|
+
|
|
263
|
+
const decision = yield* Schema.decodeUnknownEffect(CredentialObservationDecision)(
|
|
264
|
+
rawDecision,
|
|
265
|
+
).pipe(Effect.mapError(() => fail("observation-blocked")));
|
|
266
|
+
|
|
267
|
+
if (decision === "deny") return yield* fail("observation-blocked");
|
|
268
|
+
if (typeof decision !== "string") {
|
|
269
|
+
origins = [...decision.origins];
|
|
270
|
+
if (!origins.includes(context.topOrigin)) return yield* fail("observation-blocked");
|
|
271
|
+
}
|
|
253
272
|
observation = "approved-after-exposure";
|
|
254
273
|
}
|
|
274
|
+
yield* remote(driver.restrictObservation(origins));
|
|
255
275
|
|
|
256
|
-
return
|
|
276
|
+
return {
|
|
277
|
+
...context,
|
|
278
|
+
frameOrigins: context.frameOrigins.filter(
|
|
279
|
+
(origin) => origins === undefined || origins.includes(origin),
|
|
280
|
+
),
|
|
281
|
+
};
|
|
257
282
|
});
|
|
258
283
|
|
|
259
284
|
const target = (ref: string) => remote(driver.target(ref));
|
|
260
285
|
|
|
286
|
+
const authorizeAction = Effect.fn("ProtectedBrowser.authorizeAction")(function* (
|
|
287
|
+
action: ProtectedBrowserAction,
|
|
288
|
+
) {
|
|
289
|
+
if (access.authorizeAction === undefined) {
|
|
290
|
+
if (action._tag === "Submit") return yield* fail("unsupported");
|
|
291
|
+
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const principal = yield* caller;
|
|
295
|
+
|
|
296
|
+
yield* access
|
|
297
|
+
.authorizeAction({ caller: principal, action, exposures: [...exposures] })
|
|
298
|
+
.pipe(Effect.mapError((error) => fail(error.reason)));
|
|
299
|
+
if (Redacted.value(yield* caller) !== Redacted.value(principal))
|
|
300
|
+
return yield* fail("denied");
|
|
301
|
+
if (action._tag !== "Navigate") {
|
|
302
|
+
const current = yield* target(action.ref);
|
|
303
|
+
|
|
304
|
+
if (
|
|
305
|
+
!sameTarget(current.target, action.target) ||
|
|
306
|
+
current.role !== (action._tag === "Submit" ? "submit" : action.role) ||
|
|
307
|
+
(action._tag === "Click" && action.role === "link" && current.url !== action.url)
|
|
308
|
+
)
|
|
309
|
+
return yield* fail("stale-reference");
|
|
310
|
+
}
|
|
311
|
+
}, Effect.withTracerEnabled(false));
|
|
312
|
+
|
|
261
313
|
const bounded = <A>(result: A) => {
|
|
262
314
|
const bytes = new TextEncoder().encode(JSON.stringify(result)).byteLength;
|
|
263
315
|
|
|
@@ -366,6 +418,7 @@ export const browserRunProtectedLayer = () =>
|
|
|
366
418
|
)
|
|
367
419
|
return yield* fail("denied");
|
|
368
420
|
if (exposures.length > 0) yield* permitObservation;
|
|
421
|
+
yield* authorizeAction({ _tag: "Navigate", url: decoded.url });
|
|
369
422
|
offers.clear();
|
|
370
423
|
dispatch = "possibly-dispatched";
|
|
371
424
|
yield* remote(driver.navigate(decoded.url));
|
|
@@ -379,8 +432,14 @@ export const browserRunProtectedLayer = () =>
|
|
|
379
432
|
const result = yield* remote(driver.discover);
|
|
380
433
|
const after = yield* permitObservation;
|
|
381
434
|
|
|
382
|
-
if (
|
|
435
|
+
if (
|
|
436
|
+
before.document !== after.document ||
|
|
437
|
+
result.document !== after.document ||
|
|
438
|
+
result.topOrigin !== after.topOrigin
|
|
439
|
+
)
|
|
383
440
|
return yield* fail("stale-reference");
|
|
441
|
+
if (result.frameOrigins.some((origin) => !after.frameOrigins.includes(origin)))
|
|
442
|
+
return yield* fail("observation-blocked");
|
|
384
443
|
|
|
385
444
|
return yield* bounded(
|
|
386
445
|
ProtectedBrowserObservation.make({
|
|
@@ -400,12 +459,78 @@ export const browserRunProtectedLayer = () =>
|
|
|
400
459
|
yield* permitObservation;
|
|
401
460
|
const control = yield* target(decoded.ref);
|
|
402
461
|
|
|
403
|
-
|
|
404
|
-
|
|
462
|
+
if (
|
|
463
|
+
control.role !== "link" &&
|
|
464
|
+
control.role !== "button" &&
|
|
465
|
+
control.role !== "radio" &&
|
|
466
|
+
control.role !== "checkbox" &&
|
|
467
|
+
control.role !== "submit"
|
|
468
|
+
)
|
|
405
469
|
return yield* fail("unsupported");
|
|
470
|
+
let action: ProtectedBrowserAction;
|
|
471
|
+
|
|
472
|
+
if (control.role === "link") {
|
|
473
|
+
if (control.url === undefined) return yield* fail("unsupported");
|
|
474
|
+
action = {
|
|
475
|
+
_tag: "Click",
|
|
476
|
+
ref: decoded.ref,
|
|
477
|
+
target: control.target,
|
|
478
|
+
role: "link",
|
|
479
|
+
url: control.url,
|
|
480
|
+
};
|
|
481
|
+
} else if (control.role === "submit") {
|
|
482
|
+
action = { _tag: "Submit", ref: decoded.ref, target: control.target };
|
|
483
|
+
} else {
|
|
484
|
+
action = {
|
|
485
|
+
_tag: "Click",
|
|
486
|
+
ref: decoded.ref,
|
|
487
|
+
target: control.target,
|
|
488
|
+
role: control.role,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
yield* authorizeAction(action);
|
|
406
492
|
dispatch = "possibly-dispatched";
|
|
407
|
-
yield* remote(driver.click(decoded.ref))
|
|
493
|
+
yield* remote(driver.click(decoded.ref)).pipe(
|
|
494
|
+
Effect.catch((error) => {
|
|
495
|
+
if (error.reason === "needs-attention") dispatch = "not-dispatched";
|
|
496
|
+
|
|
497
|
+
return Effect.fail(error);
|
|
498
|
+
}),
|
|
499
|
+
);
|
|
500
|
+
dispatch = "dispatched";
|
|
501
|
+
if (control.role === "submit") milestone = "submission-dispatched";
|
|
502
|
+
yield* permitObservation;
|
|
503
|
+
}),
|
|
504
|
+
),
|
|
505
|
+
fill: (request) =>
|
|
506
|
+
run(
|
|
507
|
+
Effect.gen(function* () {
|
|
508
|
+
const decoded = yield* Schema.decodeUnknownEffect(ProtectedBrowserFill)(
|
|
509
|
+
request,
|
|
510
|
+
).pipe(Effect.mapError(() => fail("denied")));
|
|
511
|
+
|
|
512
|
+
yield* permitObservation;
|
|
513
|
+
const control = yield* target(decoded.ref);
|
|
514
|
+
|
|
515
|
+
if (control.role !== "text" && control.role !== "select")
|
|
516
|
+
return yield* fail("unsupported");
|
|
517
|
+
yield* authorizeAction({
|
|
518
|
+
_tag: "Fill",
|
|
519
|
+
ref: decoded.ref,
|
|
520
|
+
target: control.target,
|
|
521
|
+
role: control.role,
|
|
522
|
+
});
|
|
523
|
+
yield* remote(
|
|
524
|
+
driver.fill(decoded.ref, control.role, Redacted.make(decoded.value)),
|
|
525
|
+
).pipe(
|
|
526
|
+
Effect.provideService(ProtectedBrowserDispatch, {
|
|
527
|
+
mark: Effect.sync(() => {
|
|
528
|
+
dispatch = "possibly-dispatched";
|
|
529
|
+
}),
|
|
530
|
+
}),
|
|
531
|
+
);
|
|
408
532
|
dispatch = "dispatched";
|
|
533
|
+
milestone = "filled";
|
|
409
534
|
yield* permitObservation;
|
|
410
535
|
}),
|
|
411
536
|
),
|
|
@@ -475,8 +600,6 @@ export const browserRunProtectedLayer = () =>
|
|
|
475
600
|
|
|
476
601
|
if (Redacted.value(principal) !== Redacted.value(offer.caller))
|
|
477
602
|
return yield* fail("denied");
|
|
478
|
-
if (offer.kind === "card" && decoded.submit !== undefined)
|
|
479
|
-
return yield* fail("unsupported");
|
|
480
603
|
if (
|
|
481
604
|
new Set(decoded.fields.map((field) => field.ref)).size !==
|
|
482
605
|
decoded.fields.length ||
|
|
@@ -522,6 +645,8 @@ export const browserRunProtectedLayer = () =>
|
|
|
522
645
|
yield* access
|
|
523
646
|
.authorize(authorization)
|
|
524
647
|
.pipe(Effect.mapError((error) => fail(error.reason)));
|
|
648
|
+
if (Redacted.value(yield* caller) !== Redacted.value(principal))
|
|
649
|
+
return yield* fail("denied");
|
|
525
650
|
});
|
|
526
651
|
|
|
527
652
|
yield* authorize;
|