Skip to content

[6.x] Compare field conditions without eval() - #15564

Merged
jasonvarga merged 2 commits into
statamic:6.xfrom
bpmore:field-conditions-without-eval
Oct 5, 2026
Merged

jasonvarga merged 2 commits into
statamic:6.xfrom
bpmore:field-conditions-without-eval

Conversation

@bpmore

@bpmore bpmore commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #15552.

Validator::passesCondition() decides every plain comparison (==, !=, ===, !==, >, >=, < and <=, so is, equals, not, isnt and the shorthand too) by building a source string and passing it to eval(). Under a Content Security Policy that omits 'unsafe-eval', that call throws and conditional fields stop working.

This replaces the eval() with a table of those eight comparisons.

Keeping eval()'s results

prepareLhs() and prepareRhs() JSON-encode string operands, and eval() read them back as string literals. Comparing the prepared operands directly would change results, so:

  • String operands are decoded with JSON.parse() before comparing, which gives eval()'s values. Without that, a number field compared with a condition value (age: '13' against 13) is compared with the encoded "13", quotes included, and stops matching.
  • Everything else is compared as is. An undefined operand is not valid JSON, so decoding every operand would throw where eval() returned a result.
  • An operator outside the eight throws an Error, as eval() threw on a source string it could not parse.

prepareLhs(), prepareRhs(), and the includes, includes_any, contains_any, empty and custom paths are unchanged. Only the three comments that referred to eval() are updated. No compiled files are included.

The narrower change was chosen over returning native values from prepareLhs()/prepareRhs(). That would also remove the encoding, but contains_any matches against the encoded string, so its results for values containing a quote character would change.

Tests

Added to resources/js/tests/FieldConditionsValidator.test.js:

  • No eval(): every plain operator, null, true and empty evaluate with globalThis.eval replaced by a function that throws.
  • Number against string: a number field against a string condition value, with ==, !=, === and the shorthand.
  • Missing field: a field with no value compares as null.
  • undefined operand: passesCondition() with an undefined operand returns eval()'s results rather than throwing.

npx vitest run --project unit: 70 of 71 files pass. LivePreview.test.js fails identically on 6.x without this change (localStorage is undefined in that environment).

Against the unchanged eval() implementation, all of the new tests except the no-eval() one pass. That shows the results are unchanged. Two naive rewrites each fail at least one test:

  • comparing the encoded operands without decoding: the number-against-string test, and the existing comparison-operator test;
  • decoding every operand: the undefined-operand test.

This was prepared with AI assistance and reviewed before submitting.

bpmore and others added 2 commits October 1, 2026 20:03
Validator::passesCondition() decided every plain operator comparison by
building a source string and passing it to eval(), so conditional fields
stop working under a Content Security Policy that omits 'unsafe-eval'.

Compare through a table of the eight operators instead. prepareLhs() and
prepareRhs() JSON-encode string operands, so those are decoded back to the
values eval() read before comparing; everything else is compared as is.
That keeps eval()'s results, including a number field against a string
condition value and an undefined operand.

Fixes statamic#15552.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BunmG83RzfEoLnXCnQZ9Kq
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jasonvarga
jasonvarga merged commit 24933a5 into statamic:6.x Oct 5, 2026
61 checks passed
joshdaugherty added a commit to UAMS-Web/aether-alpine-csp-forms that referenced this pull request Oct 27, 2026
Closes #18.

The `$showField` magic now forwards to
`window.Statamic.$conditions.showField`, which Statamic's front-end
`helpers.js` defines, and the add-on's own copy of the field-conditions
evaluator is removed. Public forms and the Control Panel then evaluate
conditions with the same code. `statamic/cms` `v6.35.1` is the first
release whose evaluator runs without `eval()` (`statamic/cms#15564`), so
the add-on now requires `^6.35.1`.

## Approach

-
[`resources/js/conditions/showFieldMagic.js`](https://github.com/UAMS-Web/statamic-alpine-csp-forms/blob/18-forward-showfield-to-statamic/resources/js/conditions/showFieldMagic.js)
reads `window.Statamic.$conditions` on every call, not at registration,
so a host that loads `helpers.js` after the add-on's script still works
once it has loaded.
- **Custom conditions**: Statamic's front-end `$conditions` defines
`showField()` only, and its evaluator looks a `custom` condition up with
`get()`, which exists in the Control Panel alone. Where `get()` is
missing, the magic supplies one that reads
`window.StatamicCustomConditions`, so a registered condition decides the
field and an unregistered one hides it with Statamic's "was not properly
defined" error. An existing `get()` is left in place.
- **Missing global**: the magic shows the field and logs one console
error per registration naming `/vendor/statamic/frontend/js/helpers.js`.
A throwing evaluator still fails open, as before.
- **Removed**: `validator.js`, `constants.js`, `converter.js`,
`parentResolver.js`, `support.js`, `showField.js`, their tests, and
`tests/Unit/FieldConditionsConstantsParityTest.php`.
- **Tests**:
[`showFieldMagic.test.js`](https://github.com/UAMS-Web/statamic-alpine-csp-forms/blob/18-forward-showfield-to-statamic/resources/js/conditions/showFieldMagic.test.js)
loads the installed
`vendor/statamic/cms/resources/dist-frontend/js/helpers.js`, so the
real-interpreter cases (non-ASCII values, live `$data`, the field path,
the rendered form) run against Statamic's evaluator. A stubbed global
covers forwarding, and a missing one covers the console error.
- **Docs**: the README and the wiki's Installation, Driver reference and
Troubleshooting pages say to load `helpers.js` before `alpine-csp.js`,
and give the `6.35.1` floor. `docs/DECISIONS.md` records the decision.
- `resources/dist/frontend/alpine-csp.js` is rebuilt: 859 bytes, down
from 7,357.

## Breaking change

A host must load Statamic's `helpers.js` ahead of `alpine-csp.js`.
Without it every conditional field is shown and the console names the
missing script. In `uams-statamic` that is UAMS-Web/uams-statamic#1931.

## Test plan

- [x] `composer ci` on Windows 11: Pint, Rector, PHPStan, 57 Pest tests
and 125 Vitest tests passed
- [x] `npx vitest run resources/js`: 15 passed on Windows 11
- [x] Negative controls: replacing the forward with `return true` turns
8 of the 12 forwarding and evaluation tests red, including the
stubbed-global case. Removing the once-only guard turns the
missing-global case red (3 calls, not 1). Removing the custom-condition
lookup turns both lookup tests red, and overwriting an existing `get()`
turns the keep-it test red. All restored to green.
- [x] `git grep` at the head finds no reference to the deleted files
outside `docs/DECISIONS.md` (the same expression finds them on the
parent commit)

## After merging

A release is tagged so UAMS-Web/uams-statamic#1931 can require it. The
tag is left for the release owner.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Field conditions use eval(), so a Content Security Policy without 'unsafe-eval' breaks conditional fields

2 participants