In a Ruby report builder, why is rows.sort_by { |r| r.public_send(params[:sort]) } still unsafe, and how do you allowlist it?
answer
- visibility rule, not a security boundary
- private global functions are blocked
- send and instance_eval are public
- respond_to? says yes to them too
- frozen Hash of strings to symbols
basics
~20 spublic_send only refuses private methods; every public method stays reachable, including send, instance_eval, freeze and the model's own mutators. Map the request value through a fixed hash of permitted sort keys and dispatch on the symbol you chose.
solid answer
~40 s`public_send` refuses private methods, so `r.public_send("eval", code)` raises `NoMethodError`, but that is a visibility rule, not a security boundary. With no arguments, the request can still call any public zero-argument method on each row: `freeze`, `instance_variables`, or a mutator your own class exposes. Give the attacker one argument and it is code execution: `instance_eval` is public on `BasicObject`, and `send` itself is public, so `public_send("send", "eval", code)` walks straight past the check. `respond_to?` and `public_method_defined?` do not help, because they answer yes for exactly those methods. The fix is an allowlist that maps request strings to symbols you wrote, such as `SORT_KEYS.fetch(params[:sort], :created_at)`, then dispatching on that symbol.
code
ruby · 4 lineso = Object.new
o.public_send("eval", "1 + 1") # NoMethodError: eval is private
o.public_send("instance_eval", "40 + 2") # => 42
o.public_send("send", "eval", "2 + 2") # => 4go deeper
Remember that send and public_send call whatever method name they are given, so a name from a request must be checked against a fixed list first.
Explain what public_send blocks (private methods) and what it does not: public send, instance_eval and freeze, with and without extra arguments.
Show an allowlist mapping request strings to symbols, explain why respond_to? and instance_methods are not allowlists, and test unknown keys.
Frame dynamic dispatch as an interface decision: the request vocabulary is a contract, declared per feature, never derived from the class's method list.
## What public_send actually checks `Kernel#public_send` converts its first argument to a symbol and calls that method **only if it is public**. Ruby's "global functions" (`eval`, `exit!`, `require`, `open` and the rest) are private methods of `Kernel`, so calling them through `public_send` raises `NoMethodError`. That is the whole of its protection. It is a **visibility** rule designed for API hygiene, and it says nothing about whether a public method is safe for an anonymous user to trigger. ## What a request can still reach Every object inherits a large public surface from `Object`, `Kernel` and `BasicObject`, plus whatever your own class exposes: | Request sends | Arguments needed | Effect | |---|---|---| | `"eval"` or `"exit!"` | any | `NoMethodError`: private and protected methods are all `public_send` refuses | | `"freeze"` | 0 | every row is frozen; later writes raise `FrozenError` | | `"instance_variables"` | 0 | leaks the object's internal state names | | a public mutator on your class | 0 | changes state on every row the sort touches | | `"instance_eval"` | 1 | the argument is parsed and run as Ruby | | `"send"` | 1 or more | `send` is public and calls private methods: `exit!` with one argument, `eval` with two | The sort case passes no arguments, so it is "only" arbitrary zero-argument calls. The same pattern with a second request value, such as `r.public_send(params[:filter], params[:value])`, is full remote code execution through `instance_eval`. Ruby's own security guide makes the point directly: `public_send` is also dangerous, because `send` itself is public. ## Checks that look like allowlists but are not - **`respond_to?(name)`** answers "is there a public method with this name?". It returns true for `send`, `instance_eval` and `freeze`. - **`Report.public_method_defined?(name)`** is the same question asked of the class, with the same answers. - **`Report.instance_methods(false)`** is closer, since it lists only methods defined in that class, but it silently grows: every public method anyone adds tomorrow becomes sortable, including mutators. - **`method_missing`** on the model means `public_send` succeeds for names that no list of defined methods contains, so checks derived from the class's methods do not describe what can actually be called. - **`to_sym` first** changes nothing about safety. It is no longer a memory-exhaustion risk either, because dynamically created symbols are garbage-collected, but it restricts nothing. ## Building the allowlist 1. **Declare the permitted values once**, next to the code that offers them in the UI: a frozen `Hash` from the strings the client may send to the symbols you will call. 2. **Resolve with `Hash#fetch`**, either with a default (`fetch(params[:sort], :created_at)`) or a block that raises, which you map to a 400 response. 3. **Dispatch on the resolved symbol only.** At this point `send` and `public_send` behave the same for your keys; `public_send` still documents that you expect public methods. 4. **Test the table:** one test that every key sorts, and one that an unknown key such as `"instance_eval"` falls back or raises. Decoupling the request vocabulary from method names has a second benefit: renaming `created_at` no longer breaks bookmarked URLs, because only the table changes. ## Where else the pattern applies - `method(name)` and `public_method(name)` return a callable for whatever name they get, so they share this problem. - `instance_variable_get(name)` reads any instance variable the request names. - Filter, group-by and export options usually arrive through the same kind of parameter as the sort column, and each needs its own table. ## Recognising it in review - **Any dispatch on a request value**: `send(params[...])`, `public_send(params[...])`, `__send__`, or `method(params[...]).call`. - **Prefixes and suffixes are not allowlists.** `public_send("export_#{kind}")` narrows the choice to methods with that prefix, but the request still picks any of them, including helpers added later for internal use. - **Names that travel through storage.** A saved report definition that records a sort column is request data too; it was chosen by a user, only earlier. - **Error messages as oracles.** A `NoMethodError` echoed back to the client tells an attacker which names exist and which are private, so return a plain 400 for unknown keys. A good answer in an interview names the mechanism (visibility only), gives a concrete bypass (`send` or `instance_eval` are public), and replaces reflection with a declared table rather than a stronger check on the name.
- Why is Report.instance_methods(false) a weak allowlist for sort columns?It is derived from the code rather than declared for the feature, so every public method anyone later defines on `Report` becomes sortable, including ones that mutate state or run queries. An explicit table only grows when someone deliberately adds a sortable column.
- If the dynamic call must accept an argument from the request, what extra risk appears?Any public one-argument method becomes reachable. `instance_eval` takes a string of Ruby code, and `send` with one argument calls any private method such as `exit!`, so the call becomes code execution or denial of service. With an allowlist the argument is just data for a method you chose, and you still validate it for that method.
saying these in an interview costs you the question
- public_send is safe with user input because it cannot call private methods.
- Checking respond_to? before public_send makes the method name safe.
- Only private methods like eval and system are dangerous to dispatch dynamically.
- Converting the name with to_sym restricts it to methods that already exist.
- Allowlisting method names means the UI can no longer add sort options.