web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
src/wpe/formwatch.rs (21.2K)
1 //! The page half of account autocomplete: what the chrome knows about a
2 //! login form, how a picked account gets into it, and how a new sign-in is
3 //! offered for saving.
4 //!
5 //! Everything runs in a **private script world** (`WORLD`), not the page's.
6 //! Two things follow, and they are the reason for the whole arrangement: the
7 //! page cannot see or replace the helpers this installs, so it cannot hook the
8 //! moment a credential is filled; and the message channels the chrome listens
9 //! on cannot be spoofed by page script, so a page cannot make the chrome
10 //! believe a login field is focused when none is. Inside that world
11 //! `location.origin` is the frame's real origin, so what a watcher reports
12 //! about *where* it is can be believed.
13 //!
14 //! The watcher runs in **every frame**. Sign-in forms are often a frame of
15 //! their own — iCloud's is `idmsa.apple.com` inside `www.icloud.com` — and
16 //! that frame is where the credential actually goes, so it is the frame's
17 //! origin the chrome matches accounts against (see `on_form_event` in
18 //! `main.rs` for what else is offered there, and how it is labelled). Two
19 //! things a top-frame-only watcher got for free have to be rebuilt:
20 //!
21 //! * **Position.** A frame only knows its fields relative to its own
22 //! viewport. Each frame names itself with a random token and announces it
23 //! to its parent with `postMessage`; the parent's watcher finds which of its
24 //! frames sent it (`event.source`), adds that frame's offset, and passes it
25 //! up, until the top reports `{t: 'frame', offset}` to the chrome. Only
26 //! geometry travels this way — the page could forge a relay, and the worst
27 //! a forgery does is draw the list in the wrong place.
28 //! * **Filling.** The chrome can evaluate script only in the top frame. So a
29 //! frame with a focused login field *asks* to be filled, on `FILL_CHANNEL`,
30 //! a channel with a reply: the chrome holds the newest ask, and answers it
31 //! with the picked credential or with nothing. The credential therefore
32 //! goes to exactly the frame that asked, and never through the page.
33
34 /// The isolated world everything here lives in.
35 pub const WORLD: &str = "cce-accounts";
36 /// The message channel the injected script reports on.
37 pub const CHANNEL: &str = "cceAccounts";
38 /// The channel a frame asks to be filled on; its replies carry credentials.
39 pub const FILL_CHANNEL: &str = "cceAccountsFill";
40
41 /// Watches a frame for login fields and reports them to the chrome.
42 ///
43 /// It reports *positions*, *field kinds* and *what is typed into a username
44 /// field* — never page content at large. The password crosses only on a
45 /// submit, as the thing being offered for saving. Rects are CSS pixels
46 /// relative to the frame's viewport; a frame's offset in the top document
47 /// arrives separately, as a `frame` event.
48 pub const WATCH_JS: &str = r#"
49 (() => {
50 const handlers = window.webkit && window.webkit.messageHandlers;
51 if (!handlers || !handlers.cceAccounts) return;
52 const post = (m) => {
53 try { handlers.cceAccounts.postMessage(JSON.stringify(m)); } catch (e) {}
54 };
55 const isTop = window === window.top;
56 // The chrome's name for this document, random so that nothing can aim a
57 // relay or a fill at a document it did not see announced. The top frame
58 // gets one too: every tab's top frame shares these channels, and a fill
59 // meant for this tab must not be answerable by another tab's page.
60 const token = Array.from(crypto.getRandomValues(new Uint8Array(12)),
61 (b) => b.toString(16).padStart(2, '0')).join('');
62 // Every tab's watchers share the chrome's channels, and nothing on them says
63 // which tab spoke. Focus does: only the shown tab's view is focused, so a
64 // document without it is in a background tab (or a window the person left)
65 // and has nothing to report or ask for. A page cannot fake this from its
66 // own world.
67 const live = () => document.hasFocus();
68 const state = { user: null, pass: null, filling: false, typed: '', sent: '' };
69 window.__cceAccounts = state;
70
71 const isPassword = (el) =>
72 el && el.tagName === 'INPUT' && el.type === 'password' && !el.disabled && !el.readOnly;
73 // A username field is a text-ish input that keeps company with a password
74 // one: same form, or — for the many login pages that use no form element —
75 // anywhere on a page that has one. Autocomplete hints and the usual names
76 // are accepted on their own, since some pages ask for the username first
77 // and only render the password field on the next step.
78 const textish = (el) =>
79 el && el.tagName === 'INPUT' &&
80 ['text', 'email', 'tel', ''].includes((el.type || '').toLowerCase()) &&
81 !el.disabled && !el.readOnly;
82 const named = (el) => {
83 const hint = ((el.autocomplete || '') + ' ' + (el.name || '') + ' ' +
84 (el.id || '') + ' ' + (el.getAttribute('aria-label') || '')).toLowerCase();
85 return /user|email|login|account|ident/.test(hint);
86 };
87 const passwordsIn = (root) =>
88 Array.from((root || document).querySelectorAll('input[type=password]'))
89 .filter(isPassword);
90
91 const kindOf = (el) => {
92 if (isPassword(el)) return 'pass';
93 if (!textish(el)) return null;
94 const form = el.form;
95 if (passwordsIn(form).length) return 'user';
96 if (named(el) && passwordsIn(document).length) return 'user';
97 if (named(el) && el.type.toLowerCase() === 'email') return 'user';
98 return null;
99 };
100
101 const rectOf = (el) => {
102 const r = el.getBoundingClientRect();
103 return [r.left, r.top, r.width, r.height];
104 };
105
106 // ---- where this frame is, relayed up to the top ----
107
108 // The child frame whose field is focused, as last announced through here:
109 // a scroll in this document moves it, and only this document can say so.
110 let child = null;
111 const isFrame = (el) => el && (el.tagName === 'IFRAME' || el.tagName === 'FRAME');
112 const relay = (el, frame, inner) => {
113 const r = el.getBoundingClientRect();
114 const cs = getComputedStyle(el);
115 const dx = r.left + el.clientLeft + (parseFloat(cs.paddingLeft) || 0) + inner[0];
116 const dy = r.top + el.clientTop + (parseFloat(cs.paddingTop) || 0) + inner[1];
117 child = { el, frame, inner };
118 if (isTop) {
119 post({ t: 'frame', frame, offset: [dx, dy] });
120 } else {
121 window.parent.postMessage({ cceAccountsFrame: frame, dx, dy }, '*');
122 }
123 };
124 const announce = () => {
125 if (!isTop && live()) window.parent.postMessage({ cceAccountsFrame: token, dx: 0, dy: 0 }, '*');
126 };
127 window.addEventListener('message', (e) => {
128 const d = e.data;
129 if (!d || typeof d !== 'object' || typeof d.cceAccountsFrame !== 'string') return;
130 // Ours: the page has no use for it, so it never sees it.
131 e.stopImmediatePropagation();
132 const el = Array.from(document.querySelectorAll('iframe, frame'))
133 .find((f) => f.contentWindow === e.source);
134 if (!el) return;
135 relay(el, d.cceAccountsFrame, [Number(d.dx) || 0, Number(d.dy) || 0]);
136 }, true);
137
138 // ---- filling, on request ----
139
140 const fill = (cred) => {
141 // Suppress the watcher for the duration: dispatching `input` is the whole
142 // point of filling, and it must not come back as typing. Synchronous, so
143 // the flag is down again before anything else runs.
144 state.filling = true;
145 // Values go in through the prototype's own `value` setter and are
146 // followed by `input` and `change`: frameworks that track their inputs
147 // (React's value tracker above all) ignore a plain assignment, and a page
148 // whose state never saw the credential appear submits an empty form.
149 const set = (el, v) => {
150 if (!el || !el.isConnected) return false;
151 const d = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value');
152 if (d && d.set) { d.set.call(el, v); } else { el.value = v; }
153 el.dispatchEvent(new Event('input', { bubbles: true }));
154 el.dispatchEvent(new Event('change', { bubbles: true }));
155 return true;
156 };
157 const user = String(cred.u || '');
158 const filledUser = user.length ? set(state.user, user) : false;
159 if (user.length) state.typed = user;
160 const filledPass = set(state.pass, String(cred.p || ''));
161 // A username-first page: the password field is not there yet.
162 if (filledUser && !filledPass && state.user) state.user.focus();
163 state.filling = false;
164 };
165 // One ask outstanding per document. The chrome keeps only the newest ask
166 // from any frame and answers a superseded one with nothing, which is what
167 // clears this flag for the next focus.
168 let asking = false;
169 const ask = () => {
170 if (asking || !live()) return;
171 asking = true;
172 let pending;
173 try { pending = handlers.cceAccountsFill.postMessage(token); } catch (e) { asking = false; return; }
174 Promise.resolve(pending).then((r) => {
175 asking = false;
176 if (typeof r === 'string' && r) fill(JSON.parse(r));
177 }, () => { asking = false; });
178 };
179
180 // ---- login fields ----
181
182 const report = (el, kind, type) => {
183 // A fill is not something to report back: the input events it dispatches
184 // would arrive as "the user typed", re-opening the list that was just
185 // used and filtering it by the name it had just filled in.
186 if (state.filling || !live()) return;
187 if (kind === 'pass') { state.pass = el; } else { state.user = el; }
188 // Remember the pair, so filling reaches both fields from either one.
189 const form = el.form;
190 const pass = passwordsIn(form).concat(passwordsIn(document))[0] || null;
191 if (pass) state.pass = pass;
192 if (kind === 'user') { state.user = el; state.typed = el.value || state.typed; }
193 post({
194 t: type,
195 kind: kind,
196 frame: token,
197 top: isTop,
198 origin: location.origin,
199 rect: rectOf(el),
200 value: kind === 'pass' ? '' : (el.value || ''),
201 });
202 announce();
203 if (type === 'focus') ask();
204 };
205
206 document.addEventListener('focusin', (e) => {
207 const kind = kindOf(e.target);
208 if (kind) report(e.target, kind, 'focus');
209 }, true);
210
211 document.addEventListener('focusout', (e) => {
212 if (kindOf(e.target)) post({ t: 'blur', frame: token });
213 }, true);
214
215 // Typing in the username field is the filter; the password field's own
216 // text is never reported.
217 document.addEventListener('input', (e) => {
218 const kind = kindOf(e.target);
219 if (kind === 'user' && document.activeElement === e.target) {
220 report(e.target, kind, 'input');
221 }
222 }, true);
223
224 // The page moving under an open list would leave it pointing at nothing.
225 const moved = () => {
226 const el = document.activeElement;
227 // Focus is inside a child frame: what moved is that frame.
228 if (isFrame(el)) {
229 if (child && child.el === el) relay(el, child.frame, child.inner);
230 return;
231 }
232 const kind = kindOf(el);
233 if (kind) report(el, kind, 'move'); else post({ t: 'blur', frame: token });
234 };
235 window.addEventListener('scroll', moved, true);
236 window.addEventListener('resize', moved, true);
237 // Focus coming back to this document — the window, or this frame within
238 // the page — while a login field is the focused element: report it, since
239 // its `focusin` was ignored while the document was not live.
240 window.addEventListener('focus', () => {
241 const el = document.activeElement;
242 const kind = kindOf(el);
243 if (kind) report(el, kind, 'focus');
244 });
245
246 // ---- a sign-in going out, offered for saving ----
247
248 // The username that goes with a password: the last username field before
249 // it that holds something, else whatever was typed into one earlier in
250 // this document — a username-first page has replaced that field by now.
251 const userFor = (pass) => {
252 const scope = pass.form || document;
253 let found = '';
254 for (const el of scope.querySelectorAll('input')) {
255 if (el === pass) break;
256 if (kindOf(el) === 'user' && el.value) found = el.value;
257 }
258 return found || state.typed || '';
259 };
260 const submitted = (scope) => {
261 if (!live()) return;
262 const pass = passwordsIn(scope).concat(passwordsIn(document)).find((p) => p.value);
263 if (!pass) return;
264 const user = userFor(pass);
265 // A form can be submitted several ways at once (Enter, then the submit
266 // event it causes); once per credential is enough.
267 const key = user + '\n' + pass.value;
268 if (key === state.sent) return;
269 state.sent = key;
270 post({ t: 'submit', frame: token, top: isTop, origin: location.origin,
271 user: user, pass: pass.value });
272 };
273 // A real form says it went out with `submit`, which fires only once the
274 // page's own validation has passed — an Enter or a button press inside a
275 // form is left to it, or a sign-in the page refused would be offered.
276 document.addEventListener('submit', (e) => submitted(e.target), true);
277 // Most sign-in pages have no form at all: a script reads the fields when
278 // Enter is pressed or the button is. Those count, and only those — a
279 // button that says it signs in, never the show-password eye beside the
280 // field.
281 document.addEventListener('keydown', (e) => {
282 if (e.key === 'Enter' && kindOf(e.target) && !e.target.form) submitted(document);
283 }, true);
284 document.addEventListener('click', (e) => {
285 const b = e.target && e.target.closest &&
286 e.target.closest('button, input[type=submit], input[type=image], [role=button]');
287 if (!b) return;
288 const type = (b.getAttribute('type') || (b.tagName === 'BUTTON' ? 'submit' : '')).toLowerCase();
289 if (b.form && (type === 'submit' || type === 'image')) return;
290 const says = ((b.textContent || '') + ' ' + (b.value || '') + ' ' + (b.id || '') + ' ' +
291 (b.getAttribute('aria-label') || '')).toLowerCase();
292 if (/sign|log ?in|continue|next|submit|enter|go\b/.test(says)) submitted(b.form);
293 }, true);
294 })();
295 "#;
296
297 /// The answer to a frame's fill ask: the credential, as a JSON string the
298 /// watcher parses. It travels as data on the reply — never spliced into a
299 /// script source — so no character in a password can become code.
300 pub fn fill_reply(username: &str, password: &str) -> String {
301 serde_json::json!({ "u": username, "p": password }).to_string()
302 }
303
304 /// A password typed into a page, on its way to being offered for saving.
305 ///
306 /// Prints as `Password(…)`: `FormEvent` derives `Debug`, and so does the
307 /// chrome's message type, so a plain `String` here would put a live password
308 /// into any log line that ever formatted one. (`accounts::Secret` is the same
309 /// idea on the keyring side; this module stays free of the app's types so the
310 /// examples can build it on its own.)
311 #[derive(Clone, PartialEq)]
312 pub struct Password(String);
313
314 impl Password {
315 pub fn expose(&self) -> &str {
316 &self.0
317 }
318 pub fn into_inner(self) -> String {
319 self.0
320 }
321 }
322
323 impl std::fmt::Debug for Password {
324 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
325 f.write_str("Password(…)")
326 }
327 }
328
329 /// What the watcher saw, as the chrome consumes it.
330 #[derive(Debug, Clone)]
331 pub enum FormEvent {
332 /// A login field took focus, or moved, or its text changed.
333 Field {
334 /// `location.origin` of the frame that reported it. For the top frame
335 /// it is checked against the tab's own URL; for a child frame it is
336 /// the site the credential would go to, and what accounts match.
337 origin: String,
338 /// The reporting document's token. A child frame's `Frame` offset
339 /// events carry the same one, and a fill is answered only to the ask
340 /// that carries the token the list was opened for.
341 frame: String,
342 /// The tab's top frame, rather than a frame inside it.
343 top: bool,
344 /// A password field rather than a username one.
345 password: bool,
346 /// Rect in CSS pixels relative to the reporting frame's viewport:
347 /// x, y, width, height.
348 rect: (f32, f32, f32, f32),
349 /// What the username field holds, for filtering. Always empty for a
350 /// password field — the chrome has no business with what is typed
351 /// into one.
352 value: String,
353 /// True when this is a re-report of a field that was already focused
354 /// (scroll, resize, typing) rather than a fresh focus.
355 moved: bool,
356 },
357 /// Where a child frame's viewport sits in the top frame's, in CSS pixels.
358 Frame { frame: String, offset: (f32, f32) },
359 /// Focus left the login field in this frame.
360 Blur { frame: String },
361 /// A sign-in went out: a form submitted, Enter in a login field, or a
362 /// sign-in button pressed with a password filled in.
363 Submit {
364 /// The frame's origin — the site these credentials belong to.
365 origin: String,
366 frame: String,
367 top: bool,
368 username: String,
369 password: Password,
370 },
371 }
372
373 /// Parse one message from the watcher. Anything unexpected is dropped: this
374 /// is a channel the chrome acts on, so it takes only what it recognizes.
375 pub fn parse_event(json: &str) -> Option<FormEvent> {
376 let value: serde_json::Value = serde_json::from_str(json).ok()?;
377 let frame = || value["frame"].as_str().unwrap_or_default().to_string();
378 match value["t"].as_str()? {
379 "blur" => Some(FormEvent::Blur { frame: frame() }),
380 "frame" => {
381 let offset = value["offset"].as_array()?;
382 let num = |i: usize| offset.get(i).and_then(|v| v.as_f64()).map(|f| f as f32);
383 let frame = frame();
384 (!frame.is_empty()).then_some(())?;
385 Some(FormEvent::Frame { frame, offset: (num(0)?, num(1)?) })
386 }
387 "submit" => {
388 let password = value["pass"].as_str()?;
389 (!password.is_empty()).then_some(())?;
390 Some(FormEvent::Submit {
391 origin: value["origin"].as_str()?.to_string(),
392 frame: frame(),
393 top: value["top"].as_bool().unwrap_or(false),
394 username: value["user"].as_str().unwrap_or_default().to_string(),
395 password: Password(password.to_string()),
396 })
397 }
398 t @ ("focus" | "input" | "move") => {
399 let rect = value["rect"].as_array()?;
400 let num = |i: usize| rect.get(i).and_then(|v| v.as_f64()).map(|f| f as f32);
401 Some(FormEvent::Field {
402 origin: value["origin"].as_str().unwrap_or_default().to_string(),
403 frame: frame(),
404 top: value["top"].as_bool().unwrap_or(false),
405 password: value["kind"].as_str() == Some("pass"),
406 rect: (num(0)?, num(1)?, num(2)?, num(3)?),
407 value: value["value"].as_str().unwrap_or_default().to_string(),
408 moved: t != "focus",
409 })
410 }
411 _ => None,
412 }
413 }
414
415 #[cfg(test)]
416 mod tests {
417 use super::*;
418
419 #[test]
420 fn a_credential_travels_as_data() {
421 // The whole hazard in one string: quotes, a backslash, a closing
422 // script tag, a newline and a line separator.
423 let nasty = "a\"b\\c</script>\nd\u{2028}e";
424 let reply = fill_reply("user", nasty);
425 let back: serde_json::Value = serde_json::from_str(&reply).unwrap();
426 assert_eq!(back["u"], "user");
427 assert_eq!(back["p"], nasty, "the password survives the trip exactly");
428 }
429
430 #[test]
431 fn events_parse_and_junk_is_dropped() {
432 let focus = parse_event(
433 r#"{"t":"focus","kind":"user","frame":"ab12","origin":"https://example.com","rect":[10,20,120,24],"value":"me"}"#,
434 );
435 match focus {
436 Some(FormEvent::Field { origin, frame, top, password, rect, value, moved }) => {
437 assert!(!top, "a missing top flag is a child frame");
438 assert_eq!(origin, "https://example.com");
439 assert_eq!(frame, "ab12");
440 assert!(!password);
441 assert_eq!(rect, (10.0, 20.0, 120.0, 24.0));
442 assert_eq!(value, "me");
443 assert!(!moved);
444 }
445 other => panic!("expected a field event, got {other:?}"),
446 }
447 assert!(matches!(
448 parse_event(r#"{"t":"blur","frame":""}"#),
449 Some(FormEvent::Blur { frame }) if frame.is_empty()
450 ));
451 assert!(matches!(
452 parse_event(r#"{"t":"input","kind":"pass","origin":"x","rect":[0,0,1,1],"value":""}"#),
453 Some(FormEvent::Field { password: true, moved: true, .. })
454 ));
455 assert!(matches!(
456 parse_event(r#"{"t":"frame","frame":"ab12","offset":[5,7.5]}"#),
457 Some(FormEvent::Frame { offset: (5.0, 7.5), .. })
458 ));
459 // Nonsense, a field event with no rect, an offset with no frame, and a
460 // submit with no password are all ignored.
461 assert!(parse_event("not json").is_none());
462 assert!(parse_event(r#"{"t":"focus","kind":"user"}"#).is_none());
463 assert!(parse_event(r#"{"t":"evil"}"#).is_none());
464 assert!(parse_event(r#"{"t":"frame","frame":"","offset":[1,1]}"#).is_none());
465 assert!(parse_event(r#"{"t":"submit","origin":"https://x.test","user":"me","pass":""}"#).is_none());
466 }
467
468 #[test]
469 fn a_submitted_password_never_prints_itself() {
470 let e = parse_event(
471 r#"{"t":"submit","frame":"","origin":"https://x.test","user":"me","pass":"hunter2"}"#,
472 )
473 .unwrap();
474 let printed = format!("{e:?}");
475 assert!(!printed.contains("hunter2"), "{printed}");
476 match e {
477 FormEvent::Submit { password, username, .. } => {
478 assert_eq!(username, "me");
479 assert_eq!(password.expose(), "hunter2");
480 }
481 other => panic!("expected a submit, got {other:?}"),
482 }
483 }
484 }