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("ed));
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 }