Skip to main content

forge_lint/sol/analysis/
access_control.rs

1//! Access-control guard detection: whether a function dominates its body with a check comparing
2//! the caller against contract state, and which state that check depends on.
3
4use super::{
5    branch_always_exits, is_require_or_assert, is_sender_member, lhs_local_var, loop_stmts,
6    stmt_expr, underlying_var, visit_stmts,
7};
8use solar::sema::{
9    Gcx,
10    hir::{self, BinOpKind, Expr, ExprKind, FunctionId, Stmt, StmtKind, UnOpKind, VariableId},
11};
12use std::{collections::HashSet, iter, ops::ControlFlow};
13
14/// True when the function or one of its modifiers contains a dominating access check.
15pub fn is_protected<'gcx>(gcx: Gcx<'gcx>, func_id: FunctionId) -> bool {
16    modifiers_and_self(gcx, func_id).any(|id| has_access_guard(gcx, id, &mut HashSet::new()))
17}
18
19/// The modifiers of `func_id` that resolve to functions, followed by `func_id` itself.
20pub fn modifiers_and_self<'gcx>(
21    gcx: Gcx<'gcx>,
22    func_id: FunctionId,
23) -> impl Iterator<Item = FunctionId> + 'gcx {
24    gcx.hir
25        .function(func_id)
26        .modifiers
27        .iter()
28        .filter_map(move |modifier| {
29            gcx.hir.function(func_id).contract.map_or_else(
30                || modifier.id.as_function(),
31                |contract| gcx.resolve_modifier_target(contract, modifier),
32            )
33        })
34        .chain(iter::once(func_id))
35}
36
37/// Whether `func_id` checks the caller before its `_` placeholder (anywhere for functions): a
38/// guarding `if`, a `require`/`assert` on an access check, or a call into a function that does.
39/// Bodyless declarations (interface functions, virtual modifiers) fall back to a name heuristic.
40pub fn has_access_guard<'gcx>(
41    gcx: Gcx<'gcx>,
42    func_id: FunctionId,
43    seen: &mut HashSet<FunctionId>,
44) -> bool {
45    if !seen.insert(func_id) {
46        return false;
47    }
48    let func = gcx.hir.function(func_id);
49    match func.body {
50        Some(body) => for_each_guard(gcx, body, seen, &mut |_| ControlFlow::Break(())).is_break(),
51        None => looks_like_access_control(func),
52    }
53}
54
55/// State variables the access checks of `func_id` and its modifiers (up to `_`) depend on.
56pub fn guard_vars<'gcx>(gcx: Gcx<'gcx>, func_id: FunctionId) -> HashSet<VariableId> {
57    let mut out = HashSet::new();
58    for id in modifiers_and_self(gcx, func_id) {
59        let Some(body) = gcx.hir.function(id).body else { continue };
60        let mut seen = HashSet::from([id]);
61        let _ = for_each_guard(gcx, body, &mut HashSet::from([id]), &mut |guard| {
62            match guard {
63                Guard::Check(cond) => expr_state_vars(gcx, cond, &mut seen, &mut out),
64                Guard::Call(callee_id) => function_state_vars(gcx, callee_id, &mut seen, &mut out),
65            }
66            ControlFlow::Continue(())
67        });
68    }
69    out
70}
71
72/// A function whose name marks it as an access check (`auth`, `onlyOwner`, `_checkRole`, ...)
73/// and that returns nothing, so calling it for its effect is meaningful.
74pub fn looks_like_access_control(func: &hir::Function<'_>) -> bool {
75    let Some(name) = func.name else { return false };
76    if !func.returns.is_empty() {
77        return false;
78    }
79    let lower = name.as_str().to_ascii_lowercase();
80    matches!(lower.as_str(), "auth" | "requiresauth" | "restricted")
81        || ["only", "check", "_check"].iter().any(|prefix| {
82            ["admin", "guardian", "manager", "owner", "role"]
83                .iter()
84                .any(|role| lower.starts_with(&format!("{prefix}{role}")))
85        })
86}
87
88/// `Some(true)` when `expr` holding means the caller is authorized, `Some(false)` when it means
89/// the caller is *not* authorized, `None` when `expr` is not an access check. An access check
90/// reads `msg.sender`/`tx.origin` (directly, through `aliases` or through a helper) and state
91/// (directly or through a helper).
92pub fn access_check_polarity<'gcx>(
93    gcx: Gcx<'gcx>,
94    expr: &Expr<'_>,
95    aliases: &HashSet<VariableId>,
96) -> Option<bool> {
97    let is_check = |sender: &Expr<'_>, authority: &Expr<'_>| {
98        expr_reads_sender(gcx, sender, &mut HashSet::new(), aliases)
99            && expr_reads_state(gcx, authority)
100    };
101    match &expr.peel_parens().kind {
102        ExprKind::Unary(op, inner) if op.kind == UnOpKind::Not => {
103            access_check_polarity(gcx, inner, aliases).map(|polarity| !polarity)
104        }
105        ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => {
106            // `a && b` is authorized as soon as one side is; `a || b` is unauthorized as soon as
107            // one side is. The opposite polarity needs both sides.
108            let dominant = op.kind == BinOpKind::And;
109            let lhs = access_check_polarity(gcx, lhs, aliases);
110            let rhs = access_check_polarity(gcx, rhs, aliases);
111            if lhs == Some(dominant) || rhs == Some(dominant) {
112                Some(dominant)
113            } else if lhs == Some(!dominant) && rhs == Some(!dominant) {
114                Some(!dominant)
115            } else {
116                None
117            }
118        }
119        ExprKind::Binary(lhs, op, rhs)
120            if matches!(op.kind, BinOpKind::Eq | BinOpKind::Ne)
121                && (is_check(lhs, rhs) || is_check(rhs, lhs)) =>
122        {
123            Some(op.kind == BinOpKind::Eq)
124        }
125        _ => is_check(expr, expr).then_some(true),
126    }
127}
128
129/// Applies `stmt` to the set of locals holding a `msg.sender`-derived value: a local initialized
130/// or assigned from a value that reads the sender becomes an alias, and one reassigned from
131/// anything else stops being one.
132fn update_sender_aliases<'gcx>(
133    gcx: Gcx<'gcx>,
134    stmt: &Stmt<'gcx>,
135    aliases: &mut HashSet<VariableId>,
136) {
137    let (var_id, value) = match stmt.kind {
138        StmtKind::DeclSingle(var_id) => (Some(var_id), gcx.hir.variable(var_id).initializer),
139        StmtKind::Expr(expr) => match &expr.peel_parens().kind {
140            ExprKind::Assign(lhs, _, rhs) => (lhs_local_var(gcx, lhs), Some(*rhs)),
141            _ => (None, None),
142        },
143        _ => (None, None),
144    };
145    if let Some(var_id) = var_id
146        && let Some(value) = value
147    {
148        if expr_reads_sender(gcx, value, &mut HashSet::new(), aliases) {
149            aliases.insert(var_id);
150        } else {
151            aliases.remove(&var_id);
152        }
153    }
154}
155
156/// Whether `expr` reads `msg.sender`/`tx.origin`, one of `aliases`, or calls a user function that
157/// reads the sender.
158pub fn expr_reads_sender<'gcx>(
159    gcx: Gcx<'gcx>,
160    expr: &Expr<'_>,
161    seen: &mut HashSet<FunctionId>,
162    aliases: &HashSet<VariableId>,
163) -> bool {
164    expr.visit(&mut |e| {
165        let reads = is_sender_member(gcx, e)
166            || underlying_var(gcx, e).is_some_and(|v| aliases.contains(&v))
167            || matches!(&e.kind, ExprKind::Call(callee, ..)
168                if matches!(callee.peel_parens().kind, ExprKind::Ident(_))
169                    && gcx.resolved_function(callee).is_some_and(|id| function_reads_sender(gcx, id, seen)));
170        if reads { ControlFlow::Break(()) } else { ControlFlow::Continue(()) }
171    })
172    .is_break()
173}
174
175/// Whether the body of `func_id` reads `msg.sender`/`tx.origin`, following calls.
176pub fn function_reads_sender<'gcx>(
177    gcx: Gcx<'gcx>,
178    func_id: FunctionId,
179    seen: &mut HashSet<FunctionId>,
180) -> bool {
181    seen.insert(func_id)
182        && gcx.hir.function(func_id).body.is_some_and(|body| {
183            visit_stmts(&gcx.hir, body.stmts, |stmt| {
184                let reads = stmt_expr(&gcx.hir, stmt)
185                    .is_some_and(|expr| expr_reads_sender(gcx, expr, seen, &HashSet::new()));
186                if reads { ControlFlow::Break(()) } else { ControlFlow::Continue(()) }
187            })
188            .is_break()
189        })
190}
191
192/// State variables read by `expr`, following calls into user functions.
193pub fn expr_state_vars<'gcx>(
194    gcx: Gcx<'gcx>,
195    expr: &Expr<'_>,
196    seen: &mut HashSet<FunctionId>,
197    out: &mut HashSet<VariableId>,
198) {
199    let _ = expr.visit(&mut |e| {
200        if let Some(var_id) = underlying_var(gcx, e)
201            && gcx.hir.variable(var_id).kind.is_state()
202        {
203            out.insert(var_id);
204        }
205        if let ExprKind::Call(callee, ..) = &e.kind
206            && matches!(callee.peel_parens().kind, ExprKind::Ident(_))
207            && let Some(callee_id) = gcx.resolved_function(callee)
208        {
209            function_state_vars(gcx, callee_id, seen, out);
210        }
211        ControlFlow::<()>::Continue(())
212    });
213}
214
215/// State variables read by the body of `func_id`, following calls into user functions.
216pub fn function_state_vars<'gcx>(
217    gcx: Gcx<'gcx>,
218    func_id: FunctionId,
219    seen: &mut HashSet<FunctionId>,
220    out: &mut HashSet<VariableId>,
221) {
222    if seen.insert(func_id)
223        && let Some(body) = gcx.hir.function(func_id).body
224    {
225        let _ = visit_stmts(&gcx.hir, body.stmts, |stmt| {
226            if let Some(expr) = stmt_expr(&gcx.hir, stmt) {
227                expr_state_vars(gcx, expr, seen, out);
228            }
229            ControlFlow::Continue(())
230        });
231    }
232}
233
234fn expr_reads_state<'gcx>(gcx: Gcx<'gcx>, expr: &Expr<'_>) -> bool {
235    let mut vars = HashSet::new();
236    expr_state_vars(gcx, expr, &mut HashSet::new(), &mut vars);
237    !vars.is_empty()
238}
239
240/// An access check among the dominating statements of a function body.
241enum Guard<'a> {
242    /// The condition of a guarding `if` or of a `require`/`assert`.
243    Check(&'a Expr<'a>),
244    /// A call into a function that itself checks the caller.
245    Call(FunctionId),
246}
247
248/// Calls `f` for every access check that dominates `body` (runs unconditionally before the `_`
249/// placeholder) until it breaks. `seen` guards the recursion into called functions.
250fn for_each_guard<'gcx>(
251    gcx: Gcx<'gcx>,
252    body: hir::Block<'gcx>,
253    seen: &mut HashSet<FunctionId>,
254    f: &mut impl FnMut(Guard<'_>) -> ControlFlow<()>,
255) -> ControlFlow<()> {
256    let mut stmts = Vec::new();
257    let _ = dominating_stmts(body.stmts, &mut stmts);
258    // Aliases as of each statement: a check is evaluated against the locals that read the sender
259    // at that point, so a reassignment neither validates a later check nor invalidates an earlier
260    // one.
261    let mut aliases = HashSet::new();
262    for stmt in stmts {
263        if let StmtKind::If(cond, then_stmt, else_stmt) = stmt.kind {
264            let exits = match access_check_polarity(gcx, cond, &aliases) {
265                Some(false) => branch_always_exits(gcx, then_stmt),
266                Some(true) => else_stmt.is_some_and(|expr| branch_always_exits(gcx, expr)),
267                None => false,
268            };
269            if exits {
270                f(Guard::Check(cond))?;
271            }
272            continue;
273        }
274        update_sender_aliases(gcx, stmt, &mut aliases);
275        let Some(expr) = stmt_expr(&gcx.hir, stmt) else { continue };
276        expr.visit(&mut |e| {
277            match &e.kind {
278                ExprKind::Call(callee, args, _) if is_require_or_assert(gcx, callee) => {
279                    if let Some(cond) = args.exprs().next()
280                        && access_check_polarity(gcx, cond, &aliases) == Some(true)
281                    {
282                        f(Guard::Check(cond))?;
283                    }
284                }
285                ExprKind::Call(callee, ..)
286                    if matches!(callee.peel_parens().kind, ExprKind::Ident(_)) =>
287                {
288                    if let Some(callee_id) = gcx.resolved_function(callee)
289                        && (looks_like_access_control(gcx.hir.function(callee_id))
290                            || has_access_guard(gcx, callee_id, seen))
291                    {
292                        f(Guard::Call(callee_id))?;
293                    }
294                }
295                _ => {}
296            }
297            ControlFlow::Continue(())
298        })?;
299    }
300    ControlFlow::Continue(())
301}
302
303/// Collects into `out` the statements that run unconditionally before the `_` placeholder (all of
304/// them for functions), descending into blocks and loops. Breaks when the placeholder is reached.
305fn dominating_stmts<'gcx>(
306    stmts: impl IntoIterator<Item = &'gcx Stmt<'gcx>>,
307    out: &mut Vec<&'gcx Stmt<'gcx>>,
308) -> ControlFlow<()> {
309    for stmt in stmts {
310        match stmt.kind {
311            StmtKind::Placeholder => return ControlFlow::Break(()),
312            StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => {
313                dominating_stmts(block.stmts, out)?;
314            }
315            StmtKind::Loop(block, source) => dominating_stmts(loop_stmts(block, source), out)?,
316            _ => out.push(stmt),
317        }
318    }
319    ControlFlow::Continue(())
320}