Skip to main content

blitz_dom/
stash_policy.rs

1use std::collections::BTreeSet;
2
3use crate::{BaseDocument, NodeId, QualName};
4
5/// Incarnation-owned policy. Revoking a marker never revokes its classification.
6#[derive(Default)]
7pub struct StashPolicy {
8    sensitive: BTreeSet<NodeId>,
9    no_stash: bool,
10}
11
12fn forbidden_autocomplete(value: &str) -> bool {
13    value.split_ascii_whitespace().any(|token| {
14        token.eq_ignore_ascii_case("off")
15            || token.eq_ignore_ascii_case("one-time-code")
16            || token.eq_ignore_ascii_case("current-password")
17            || token.eq_ignore_ascii_case("new-password")
18            || token.eq_ignore_ascii_case("cc-name")
19            || token.eq_ignore_ascii_case("cc-given-name")
20            || token.eq_ignore_ascii_case("cc-additional-name")
21            || token.eq_ignore_ascii_case("cc-family-name")
22            || token.eq_ignore_ascii_case("cc-type")
23            || token.eq_ignore_ascii_case("cc-number")
24            || token.eq_ignore_ascii_case("cc-csc")
25            || token.eq_ignore_ascii_case("cc-exp")
26            || token.eq_ignore_ascii_case("cc-exp-month")
27            || token.eq_ignore_ascii_case("cc-exp-year")
28    })
29}
30
31impl BaseDocument {
32    fn mark_stash_sensitive(&mut self, id: NodeId) {
33        if self.stash_policy.no_stash {
34            return;
35        }
36        if self.stash_policy.sensitive.len() >= 4096 {
37            self.stash_policy.no_stash = true;
38            self.stash_policy.sensitive.clear();
39        } else {
40            self.stash_policy.sensitive.insert(id);
41        }
42    }
43
44    pub(crate) fn inherit_stash_classification(&mut self, from: NodeId, to: NodeId) {
45        if self.stash_sensitive(from) {
46            self.mark_stash_sensitive(to);
47        }
48    }
49
50    pub(crate) fn note_stash_attribute(&mut self, id: NodeId, name: &QualName, value: &str) {
51        if !name.ns.as_ref().is_empty() {
52            return;
53        }
54        match name.local.as_ref() {
55            "data-chuzz-no-stash" => self.stash_policy.no_stash = true,
56            "data-chuzz-sensitive" => self.mark_stash_sensitive(id),
57            "autocomplete" if forbidden_autocomplete(value) => self.mark_stash_sensitive(id),
58            "type"
59                if ["password", "file", "hidden"]
60                    .iter()
61                    .any(|kind| value.eq_ignore_ascii_case(kind)) =>
62            {
63                self.mark_stash_sensitive(id)
64            }
65            _ => {}
66        }
67        self.note_stash_subtree(id);
68    }
69
70    fn note_stash_node(&mut self, id: NodeId) {
71        if self.stash_policy.no_stash {
72            return;
73        }
74        let Some(element) = self.get_node(id).and_then(|node| node.element_data()) else {
75            return;
76        };
77        let sensitive = element.attrs.iter().any(|attr| {
78            attr.name.ns.as_ref().is_empty()
79                && match attr.name.local.as_ref() {
80                    "data-chuzz-sensitive" => true,
81                    "autocomplete" => forbidden_autocomplete(&attr.value),
82                    "type" => ["password", "file", "hidden"]
83                        .iter()
84                        .any(|kind| str::eq_ignore_ascii_case(attr.value.as_ref(), kind)),
85                    _ => false,
86                }
87        });
88        let no_stash = element.attrs.iter().any(|attr| {
89            attr.name.ns.as_ref().is_empty() && attr.name.local.as_ref() == "data-chuzz-no-stash"
90        });
91        self.stash_policy.no_stash |= no_stash;
92        if sensitive || self.stash_sensitive(id) {
93            self.mark_stash_sensitive(id);
94        }
95    }
96
97    /// Mutations visit only the affected subtree and associated form controls.
98    pub(crate) fn note_stash_subtree(&mut self, id: NodeId) {
99        if self.stash_policy.no_stash {
100            return;
101        }
102        let associated: Vec<_> = self
103            .get_node(id)
104            .filter(|node| {
105                node.element_data()
106                    .is_some_and(|element| element.name.local.as_ref() == "form")
107            })
108            .and_then(|node| node.attr(crate::local_name!("id")))
109            .map_or_else(Vec::new, |name| {
110                self.nodes
111                    .iter()
112                    .filter_map(|(control, node)| {
113                        (node.attr(crate::local_name!("form")) == Some(name)
114                            || self.controls_to_form.get(&control) == Some(&id))
115                        .then_some(control)
116                    })
117                    .collect()
118            });
119        let mut pending = vec![id];
120        pending.extend(associated);
121        let mut visited = BTreeSet::new();
122        while let Some(id) = pending.pop() {
123            if !visited.insert(id) {
124                continue;
125            }
126            self.note_stash_node(id);
127            if let Some(node) = self.get_node(id) {
128                pending.extend(node.children.iter().copied());
129            }
130        }
131    }
132
133    /// One capture checkpoint also covers native custom-element mutation paths.
134    pub fn classify_stash(&mut self) {
135        if self.stash_policy.no_stash {
136            return;
137        }
138        let ids: Vec<_> = self.nodes.iter().map(|(id, _)| id).collect();
139        for id in ids {
140            self.note_stash_node(id);
141        }
142    }
143
144    pub fn stash_disabled(&self) -> bool {
145        self.stash_policy.no_stash
146    }
147
148    pub fn stash_sensitive(&self, id: NodeId) -> bool {
149        if self.stash_policy.no_stash {
150            return true;
151        }
152        let mut cursor = Some(id);
153        while let Some(id) = cursor {
154            if self.stash_policy.sensitive.contains(&id) {
155                return true;
156            }
157            cursor = self.get_node(id).and_then(|node| node.parent);
158        }
159        let Some(element) = self.get_node(id).and_then(|node| node.element_data()) else {
160            return false;
161        };
162        let form = element
163            .attr(crate::local_name!("form"))
164            .and_then(|name| self.get_element_by_id(name))
165            .or_else(|| self.controls_to_form.get(&id).copied());
166        form.is_some_and(|form| self.stash_policy.sensitive.contains(&form))
167    }
168}