18 Writing Safe Formulas
This chapter answers one question: how do I think about sharing a ggpaintr app with untrusted users? For building apps, start at Chapter 2; for driving ggpaintr from an LLM, Chapter 19.
ggpaintr assumes the formula author is the package user — you — while the placeholder values, including anything typed into a ppExpr box, may come from anywhere. Three boundaries sit between those two trust levels:
expr_check— controls validation of user-typedppExprcode.- The denylist + AST walker — what that validation actually does.
- The upload trust model — what happens between “user picks a file” and a data frame reaching
ggplot().
One scope note up front: ggpaintr has not been hardened for adversarial public deployment, and carries no tests for hosting, network, or process-isolation concerns. Those belong to the host environment (shinyapps.io, Posit Connect roles, container limits), not to ggpaintr’s surface.
18.1 The expr_check flag
ppExpr is the only keyword that accepts arbitrary R code. The other four — ppVar, ppText, ppNum, ppUpload — take atomic values or files, and nothing runs what the user typed as R. expr_check governs two checkpoints: at translation time it validates your entire formula — author code included, so a formula containing geom_point(data = system('id')) aborts at translation under the default — and at run time it decides what happens to whatever lands in a ppExpr box before it reaches the evaluator:
ptr_app(formula, expr_check = TRUE) # default — full denylist + walker
ptr_app(formula, expr_check = FALSE) # off — formula checks skipped (ppExpr re-screen stays on)
ptr_app(formula, expr_check = list( # custom
allow_list = c("ggplot", "aes", "geom_point"), # strict whitelist over EVERY call head in the formula
deny_list = c("get")
))expr_check is accepted on every public entry point that builds the formula’s tree — as a named argument on ptr_app(), ptr_ui(), ptr_ui_controls(), and ptr_init_state(), and via ptr_server()’s documented ... passthrough to ptr_init_state() — and defaults to TRUE in all of them.
18.1.1 The three modes
expr_check = |
Mode | What gets checked |
|---|---|---|
TRUE (or list() with no lists) |
denylist | The curated 151-entry denylist (below). Any call to, symbol naming, or string literal mentioning a denied name aborts. |
FALSE |
off | Skips the translate-time and eval-time checks on the formula. What the user types into a ppExpr box is still screened against the curated denylist when it is substituted into the plot — that re-screen is hardcoded on and cannot be disabled. |
list(deny_list = ...) |
denylist | Replaces the curated 151 with only the names you list. On its own this weakens the default — there is no “add to the curated list” form. |
list(allow_list = ...) |
allowlist | A strict whitelist over every call head in the entire formula — ggplot, aes, each geom_*, everything — not just what lands in a ppExpr box; any head not listed aborts at translation. The curated always-dangerous names (e.g. system) stay blocked as bare symbols or strings regardless. With both lists, the effective allow set is allow_list minus deny_list. |
The thing to internalise: both custom forms replace rather than extend. A bare deny_list discards the other 150 curated names, and allow_list narrows the whole formula to exactly the heads you list — only the bare-symbol/string floor stays curated. There is no “curated list plus or minus one name” form.
18.1.2 When (never) to turn it off
expr_check = FALSE is for local prototyping with trusted input — you, at the R prompt, on a private dataset. It is never right for any app another person can reach; co-workers on a shared dev server count. Note that FALSE is not a full off switch: it skips the formula checks, but ppExpr input is re-screened against the curated denylist at substitution time no matter what — typing exists("x") into the box still errors even with expr_check = FALSE.
If a name you trust is blocked in a ppExpr box (e.g. read.csv, because the denylist is conservative), there is currently no supported way to permit it. expr_check = list(allow_list = "read.csv") does not work, twice over: the allowlist is a strict whitelist over the whole formula, so the app aborts at launch because ggplot and friends aren’t listed — and even with every formula head allowlisted, the substitution-time re-screen still applies the curated denylist and blocks read.csv typed into the box. (The abort message states this plainly as of the pinned version: the runtime screen always applies to typed input and cannot be relaxed. Older builds wrongly suggested an allow_list fix here.) The working remedy is to restructure: do the trusted call in ordinary R code outside the formula and hand the result in as data.
18.2 The denylist + AST walker
The denylist is which names the validator treats as dangerous. The walker is how it hunts for them. Each does only half the job.
18.2.1 The denylist (151 entries, treated as complete)
The curated set of R names categorically unsafe from untrusted input:
| Category | Representative entries |
|---|---|
| system escape | system, system2, shell, shell.exec, pipe |
| file I/O | readLines, writeLines, readRDS, read.csv, download.file, unlink, file |
| deserialization / workspace I/O | serialize, unserialize, load, save |
| meta-eval (denylist-bypass vectors) | eval, parse, quote, bquote, do.call, match.fun, get, mget, str2lang, str2expression |
| environment / global-state mutation | <<-, ->>, assign, attach, library, loadNamespace, options |
| dangerous base / native code | on.exit, q, .Internal, .Call, .External, dyn.load |
| debugger / introspection | debug, browser, parent.frame, environment, new.env |
| info disclosure | Sys.getenv, getwd, list.files, Sys.glob, R.home |
| meta-dispatch / method injection | exec, getExportedValue, delayedAssign, setClass, setMethod, unlockBinding |
| delayed / deferred execution | reg.finalizer, addTaskCallback, setHook, packageEvent |
The list is treated as complete for the maintained branch. R is open enough that no finite enumeration is exhaustive — get("system")() would slip past any pure name-match if the walker did not also descend into string literals, compound heads, and nested calls. New entries land only when a concrete bypass is demonstrated against the walker, not against the list alone.
There is no “add one name” shortcut. expr_check = list(deny_list = "your_name") replaces the curated 151 with only "your_name", silently dropping the other 150 protections. If you think the curated list genuinely omits a dangerous name, treat it as a walker/list gap and report it upstream rather than narrowing your own deployment to a one-entry denylist.
18.2.2 The AST walker
Every ppExpr input passes through a recursive descent that turns the denylist from a list of names into a check with teeth. At each node of the parsed expression:
- Bare symbol → checked by name (catches
systemtyped alone). - String literal (length 1 or longer) → every element checked (catches
do.call("system", ...)andc("safe", "system")alike). - Pairlist (e.g. the formals of an inline
function(x)) → recurse into every element. - Call → recurse into the head and every argument. A compound head (
(system)("ls"),base::eval(parsed)) gets its head sub-expression recursed and its deparsed name checked. A placeholder annotation (ppNum(shared = "x")) is skipped: that is formula DSL, validated by the parser at translation time, not user code. - Depth cap → 100 nested levels; deeper aborts with “simplify it.”
Importantly, in allowlist mode the floor still holds: a curated name appearing as a bare symbol or string is blocked even if absent from your allow_list — the allowlist governs which call heads are permitted, not whether system may appear as data.
The walker is what makes the denylist usable. A name-only check would miss every form that builds a function reference from a string — do.call("system", list("ls")), eval(parse(text = "system('ls')")), getFromNamespace("system", "base"). The walker recurses through all of them. If you find a string that slips past both the denylist and the walker, file a bug — the walker is the primary safety mechanism and where bypasses get fixed first.
18.3 The upload trust model
ppUpload is the second untrusted-input vector. Unlike ppExpr, an upload does not execute user code — it parses user bytes as tabular data.
18.3.1 Accepted formats
| Extension | Reader | Suggested-dep package |
|---|---|---|
.csv |
utils::read.csv() (UTF-8-BOM) |
base |
.tsv |
utils::read.delim() (UTF-8-BOM) |
base |
.rds |
base::readRDS() |
base |
.xlsx, .xls |
readxl::read_excel() |
readxl |
.json |
jsonlite::fromJSON(flatten = TRUE) |
jsonlite |
Any other extension is rejected with no fallback parser. A JSON upload must be an array of records; nested fields are flattened, and any column that remains a nested array or object is rejected.
.rds is the most permissive in what bytes it accepts — an .rds file is an R serialization stream, opaque to ggpaintr until readRDS() runs. It is supported because users with data already in that format expect it; if you serve untrusted users, pair an expr_check policy with a host-level upload filter.
18.3.2 Normalization is automatic
Every successful upload passes through ptr_normalize_column_names() (non-data-frame returns from the rds/Excel/JSON readers are coerced and normalized on the same path). Names arriving with spaces, reserved words, or duplicates leave normalized, syntactic, and unique, so downstream ppVar pickers always see a clean column vector (Chapter 5). The file stem is sanitised too: a stem matching an R reserved word is renamed (if.csv → dataset name if_), not rejected — the upload still succeeds under the safe name.
18.3.3 What is and isn’t validated
The pipeline validates shape (parseable as a recognised tabular format) and names (syntactic, unique, non-reserved). It does not validate:
- Cell values. A column may hold any content the parser produces. A bad value fails at render time and surfaces through the app’s error pane; ggpaintr does not inspect cells.
- Schema. Nothing requires an upload to carry
Sepal.Lengthjust because the formula names it. A missing referenced column is a runtime error, not a parse-time rejection. - Size / rate limits. Shiny’s
shiny.maxRequestSizecaps body size (host configuration). Per-user rate limits, file-count caps, and anti-abuse policy are the host’s concern.
If you need any of these, layer them above ggpaintr — a custom source placeholder that wraps an upload and enforces a schema (Chapter 7) — or below it, at the deployment tier.