git.lucas.co / cce-browser
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 }