git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

src/wpe/formwatch.rs (11.5K)

  1 //! The page half of account autocomplete: what the chrome knows about a
  2 //! login form, and how a picked account gets into it.
  3 //!
  4 //! Both directions run in a **private script world** (`WORLD`), not the
  5 //! page's. Two things follow, and they are the reason for the whole
  6 //! arrangement: the page cannot see or replace the helpers this installs, so
  7 //! it cannot hook the moment a credential is filled; and the message channel
  8 //! the chrome listens on cannot be spoofed by page script, so a page cannot
  9 //! make the chrome believe a login field is focused when none is.
 10 //!
 11 //! The script is injected into the **top frame only**. A password field
 12 //! inside a cross-origin iframe therefore gets no suggestions — the deliberate
 13 //! trade: such a frame cannot report a position in the top document's
 14 //! coordinates, and an embedded frame asking for the embedder's credentials
 15 //! is exactly the shape of the attack this feature must not enable.
 16 
 17 /// The isolated world everything here lives in.
 18 pub const WORLD: &str = "cce-accounts";
 19 /// The message channel the injected script posts on.
 20 pub const CHANNEL: &str = "cceAccounts";
 21 
 22 /// Watches the top frame for login fields and reports them to the chrome.
 23 ///
 24 /// It reports *positions*, *field kinds* and *what is typed* — never page
 25 /// content at large. Rects are CSS pixels relative to the viewport, which the
 26 /// chrome converts with the same scale it sized the view at.
 27 pub const WATCH_JS: &str = r#"
 28 (() => {
 29   const post = (m) => {
 30     try { window.webkit.messageHandlers.cceAccounts.postMessage(JSON.stringify(m)); }
 31     catch (e) {}
 32   };
 33   const state = { user: null, pass: null, filling: false };
 34   window.__cceAccounts = state;
 35 
 36   const isPassword = (el) =>
 37     el && el.tagName === 'INPUT' && el.type === 'password' && !el.disabled && !el.readOnly;
 38   // A username field is a text-ish input that keeps company with a password
 39   // one: same form, or — for the many login pages that use no form element —
 40   // anywhere on a page that has one. Autocomplete hints and the usual names
 41   // are accepted on their own, since some pages ask for the username first
 42   // and only render the password field on the next step.
 43   const textish = (el) =>
 44     el && el.tagName === 'INPUT' &&
 45     ['text', 'email', 'tel', ''].includes((el.type || '').toLowerCase()) &&
 46     !el.disabled && !el.readOnly;
 47   const named = (el) => {
 48     const hint = ((el.autocomplete || '') + ' ' + (el.name || '') + ' ' +
 49                   (el.id || '') + ' ' + (el.getAttribute('aria-label') || '')).toLowerCase();
 50     return /user|email|login|account|ident/.test(hint);
 51   };
 52   const passwordsIn = (root) =>
 53     Array.from((root || document).querySelectorAll('input[type=password]'))
 54          .filter(isPassword);
 55 
 56   const kindOf = (el) => {
 57     if (isPassword(el)) return 'pass';
 58     if (!textish(el)) return null;
 59     const form = el.form;
 60     if (passwordsIn(form).length) return 'user';
 61     if (named(el) && passwordsIn(document).length) return 'user';
 62     if (named(el) && el.type.toLowerCase() === 'email') return 'user';
 63     return null;
 64   };
 65 
 66   const rectOf = (el) => {
 67     const r = el.getBoundingClientRect();
 68     return [r.left, r.top, r.width, r.height];
 69   };
 70 
 71   const report = (el, kind, type) => {
 72     // A fill is not something to report back: the input events it dispatches
 73     // would arrive as "the user typed", re-opening the list that was just
 74     // used and filtering it by the name it had just filled in.
 75     if (state.filling) return;
 76     if (kind === 'pass') { state.pass = el; } else { state.user = el; }
 77     // Remember the pair, so filling reaches both fields from either one.
 78     const form = el.form;
 79     const pass = passwordsIn(form).concat(passwordsIn(document))[0] || null;
 80     if (pass) state.pass = pass;
 81     if (kind === 'user') state.user = el;
 82     post({
 83       t: type,
 84       kind: kind,
 85       origin: location.origin,
 86       rect: rectOf(el),
 87       value: kind === 'pass' ? '' : (el.value || ''),
 88     });
 89   };
 90 
 91   document.addEventListener('focusin', (e) => {
 92     const kind = kindOf(e.target);
 93     if (kind) report(e.target, kind, 'focus');
 94   }, true);
 95 
 96   document.addEventListener('focusout', (e) => {
 97     if (kindOf(e.target)) post({ t: 'blur' });
 98   }, true);
 99 
100   // Typing in the username field is the filter; the password field's own
101   // text is never reported.
102   document.addEventListener('input', (e) => {
103     const kind = kindOf(e.target);
104     if (kind === 'user' && document.activeElement === e.target) {
105       report(e.target, kind, 'input');
106     }
107   }, true);
108 
109   // The page moving under an open list would leave it pointing at nothing.
110   const moved = () => {
111     const el = document.activeElement;
112     const kind = kindOf(el);
113     if (kind) report(el, kind, 'move'); else post({ t: 'blur' });
114   };
115   window.addEventListener('scroll', moved, true);
116   window.addEventListener('resize', moved, true);
117   // The chrome calls this when it has something new to offer — the account
118   // index finishing its first read, after a field was already focused.
119   state.rescan = moved;
120 })();
121 "#;
122 
123 /// Fill the remembered pair. Evaluated in [`WORLD`], so it reads the elements
124 /// the watcher recorded rather than trusting anything the page exposes.
125 ///
126 /// Values go in through the prototype's own `value` setter and are followed by
127 /// `input` and `change` events: frameworks that track their inputs (React's
128 /// value tracker above all) ignore a plain assignment, and a page whose state
129 /// never saw the credential appear will submit an empty form.
130 ///
131 /// It does not submit. Filling is the chrome's business; pressing the button
132 /// is the person's.
133 pub fn fill_js(username: &str, password: &str) -> String {
134     format!(
135         r#"
136 (() => {{
137   const s = window.__cceAccounts || {{}};
138   // Suppress the watcher for the duration: dispatching `input` is the whole
139   // point of filling, and it must not come back as typing. Synchronous, so
140   // the flag is down again before anything else runs.
141   s.filling = true;
142   const set = (el, v) => {{
143     if (!el) return false;
144     const d = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value');
145     if (d && d.set) {{ d.set.call(el, v); }} else {{ el.value = v; }}
146     el.dispatchEvent(new Event('input', {{ bubbles: true }}));
147     el.dispatchEvent(new Event('change', {{ bubbles: true }}));
148     return true;
149   }};
150   const user = {user};
151   const pass = {pass};
152   const filledUser = user.length ? set(s.user, user) : false;
153   const filledPass = set(s.pass, pass);
154   if (filledUser && !filledPass && s.user) {{ s.user.focus(); }}
155   s.filling = false;
156 }})();
157 "#,
158         user = json_string(username),
159         pass = json_string(password),
160     )
161 }
162 
163 /// A JSON string literal — the only escaping this file needs, and it has to be
164 /// exact: a credential is about to cross into a script source, where a stray
165 /// quote would end the string and the rest would be parsed as code.
166 pub fn json_string(s: &str) -> String {
167     let mut out = String::with_capacity(s.len() + 2);
168     out.push('"');
169     for c in s.chars() {
170         match c {
171             '"' => out.push_str("\\\""),
172             '\\' => out.push_str("\\\\"),
173             '\n' => out.push_str("\\n"),
174             '\r' => out.push_str("\\r"),
175             '\t' => out.push_str("\\t"),
176             // Line separators are literal newlines to a JS parser.
177             '\u{2028}' => out.push_str("\\u2028"),
178             '\u{2029}' => out.push_str("\\u2029"),
179             c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
180             c => out.push(c),
181         }
182     }
183     out.push('"');
184     out
185 }
186 
187 /// Ask the watcher to re-report whatever login field is focused right now.
188 ///
189 /// The chrome needs this exactly once per page in practice: a field can take
190 /// focus before the account index has finished its first read, and without a
191 /// nudge nothing would report it again until the person clicked away and back.
192 pub const RESCAN_JS: &str =
193     "window.__cceAccounts && window.__cceAccounts.rescan && window.__cceAccounts.rescan();";
194 
195 /// What the watcher saw, as the chrome consumes it.
196 #[derive(Debug, Clone)]
197 pub enum FormEvent {
198     /// A login field took focus, or moved, or its text changed.
199     Field {
200         /// `location.origin` of the frame that reported it, checked against
201         /// the tab's own URL before anything is offered.
202         origin: String,
203         /// A password field rather than a username one.
204         password: bool,
205         /// Viewport rect in CSS pixels: x, y, width, height.
206         rect: (f32, f32, f32, f32),
207         /// What the username field holds, for filtering. Always empty for a
208         /// password field — the chrome has no business with what is typed
209         /// into one.
210         value: String,
211         /// True when this is a re-report of a field that was already focused
212         /// (scroll, resize, typing) rather than a fresh focus.
213         moved: bool,
214     },
215     /// Focus left the login field.
216     Blur,
217 }
218 
219 /// Parse one message from the watcher. Anything unexpected is dropped: this
220 /// is a channel the chrome acts on, so it takes only what it recognizes.
221 pub fn parse_event(json: &str) -> Option<FormEvent> {
222     let value: serde_json::Value = serde_json::from_str(json).ok()?;
223     match value["t"].as_str()? {
224         "blur" => Some(FormEvent::Blur),
225         t @ ("focus" | "input" | "move") => {
226             let rect = value["rect"].as_array()?;
227             let num = |i: usize| rect.get(i).and_then(|v| v.as_f64()).map(|f| f as f32);
228             Some(FormEvent::Field {
229                 origin: value["origin"].as_str().unwrap_or_default().to_string(),
230                 password: value["kind"].as_str() == Some("pass"),
231                 rect: (num(0)?, num(1)?, num(2)?, num(3)?),
232                 value: value["value"].as_str().unwrap_or_default().to_string(),
233                 moved: t != "focus",
234             })
235         }
236         _ => None,
237     }
238 }
239 
240 #[cfg(test)]
241 mod tests {
242     use super::*;
243 
244     #[test]
245     fn a_credential_cannot_break_out_of_the_fill_script() {
246         // The whole hazard in one string: quotes, a backslash, a closing
247         // script tag, a newline and a line separator.
248         let nasty = "a\"b\\c</script>\nd\u{2028}e";
249         let quoted = json_string(nasty);
250         assert_eq!(quoted, "\"a\\\"b\\\\c</script>\\nd\\u2028e\"");
251         let js = fill_js("user", nasty);
252         assert!(js.contains(&quoted));
253         // No raw newline from the credential ever reaches the source.
254         assert!(!js.contains("d\u{2028}"));
255     }
256 
257     #[test]
258     fn events_parse_and_junk_is_dropped() {
259         let focus = parse_event(
260             r#"{"t":"focus","kind":"user","origin":"https://example.com","rect":[10,20,120,24],"value":"me"}"#,
261         );
262         match focus {
263             Some(FormEvent::Field { origin, password, rect, value, moved }) => {
264                 assert_eq!(origin, "https://example.com");
265                 assert!(!password);
266                 assert_eq!(rect, (10.0, 20.0, 120.0, 24.0));
267                 assert_eq!(value, "me");
268                 assert!(!moved);
269             }
270             other => panic!("expected a field event, got {other:?}"),
271         }
272         assert!(matches!(parse_event(r#"{"t":"blur"}"#), Some(FormEvent::Blur)));
273         assert!(matches!(
274             parse_event(r#"{"t":"input","kind":"pass","origin":"x","rect":[0,0,1,1],"value":""}"#),
275             Some(FormEvent::Field { password: true, moved: true, .. })
276         ));
277         // Nonsense, and a field event with no rect, are both ignored.
278         assert!(parse_event("not json").is_none());
279         assert!(parse_event(r#"{"t":"focus","kind":"user"}"#).is_none());
280         assert!(parse_event(r#"{"t":"evil"}"#).is_none());
281     }
282 }