Skip to content

v2.35.0 — rules that said one thing and did another

Choose a tag to compare

@sean-e-dietrich sean-e-dietrich released this 30 Sep 19:10
· 22 commits to 2.x since this release
2afaa5c

v2.35.0

Rules that said one thing and did another.

Eight reports from the WordPress and Drupal integrations. Each was a place where a rule read correctly and the firewall did something else: an ASN rule that never matched, a WordPress rule that stopped at the site root, a rate limit that counted one path under several spellings, a pairing the docs recommended that never ran. One addition: a host can now say something on the challenge page.

PHP 8.1–8.5 supported. One new configuration key, challenge.notice, and it's opt-in. Several fixes change what an existing config does, so read What changes on upgrade.

What changes on upgrade

  • ASN equality rules start matching. asn:16509, asn:AS16509 and asn@in:… never matched before, so a block rule on a network starts blocking it. asn@not_equals:X stops matching network X. It used to match every visitor, including X. Check any not_equals ASN rule.
  • The path every rule sees is normalised. A doubled slash, a . segment (raw or %2e), percent-encoded unreserved characters and ;params no longer change what a rule matches. .. is kept as written. Logs and block records show the normalised path.
  • presets/wordpress.yml and search-bots.yml are at Preset-Version 2. WordPress's paths match at any depth. They're wider (subdirectory installs, core in its own directory, readme.html anywhere) and slightly narrower: a post slug that only starts with wp-login or wp-admin is no longer blocked. See presets/CHANGELOG.md.
  • Rate-limit paths ignore case, as URL rules already did. /wp-login.php now also limits /WP-LOGIN.PHP. A pattern written as a regex keeps its own flags.
  • Redirect targets with a raw control character or whitespace go to /, as does anything a browser would read as //.
  • firewall-check --lint may report a new warning on a config it used to pass: Rule "X" has more than one entry for /login; only the first ever runs. That entry never ran.
  • The log line's url for a directly served file no longer ends in a stray /.

Added

  • challenge.notice and RequestChallenged::addNotice() (#421, #435). In mode: block the challenge page is the library's, so a host had no way to tell a visitor anything. That includes the case that asked for this: a pass cookie that never comes back, so the visitor is challenged again, forever, with nothing on the page to say why.

    challenge:
      notice: "Having trouble? Email help@example.com."   # or a list

    For a line that depends on the request, add it from a listener. For example: marker cookie present, pass absent → "Your browser did not send back the verification cookie". Notices are plain text, escaped, and shown above the form on every built-in provider's page. Custom providers get them as notices in the render context, and so does mode: exception through getRenderContext(). See A notice on the challenge page.

Fixed

  • ASN rules compared a number with text (#423, #427). Every documented ASN equality rule had never matched: asn.md, geoip-setup.md, challenges.md, tarpit.md, and rule-source templates. For a client in AS16509:

    Rule Before After
    asn:16509, asn:AS16509 no match match
    asn@in:13335,16509 no match match
    asn@not_equals:16509 match no match
    asn@contains:1650 match match (text operator, unchanged)
  • //wp-json and similar spelled around a rule (#425, #429). Under path_source: pathinfo, path came from the raw request URI, while the server normalised the URL before routing it to index.php. WordPress trims every leading slash, so //wp-json/wp/v2/users was the REST API to it and missed path@starts_with:/wp-json/. Both path sources now normalise: repeated slashes collapse, . segments go, unreserved characters are decoded (%2F stays encoded), and ;params are dropped.

    .. is kept as written, on purpose. Resolving it removes the segment before it, so /wp-json/wp/v2/…/../../../x would have been /x to the rules and still the REST API to WordPress. That would have added a way around the very rule this fixes. It was caught in review before release.

  • A solved challenge could redirect off-site (#422, #428, #433). sanitizeRedirect() accepted anything that started with one slash and not // or /\. A browser drops tab and newline from a URL, so /\t/evil.example became //evil.example. In mode: exception that reached the Location header, where PHP's header check refuses CR and LF but not tab. Raw control characters and whitespace are now refused anywhere, and \ counts as / in the path. Percent-encoded forms such as /search?q=a%20b are kept, because a browser never decodes them when working out the host.

  • WordPress's presets stopped at the site root (#420, #434). With core in its own directory (/wp/wp-login.php), or in a subdirectory install, wordpress.yml never fired, and search-bots.yml's crawler allow covered /wp/wp-login.php. Both now match at any depth, on a segment boundary, so /2026/wp-admin-tips/ is a post and not the admin. A subdirectory install still sets base_path for requests routed through its index.php.

  • The documented account-plus-address rate-limit pair never worked (#424, #430). A rate limit uses the first entry in its config: whose path matches, so the address-keyed entry the docs put second never ran, and --lint counted it as coverage. The linter now reports the unreachable entry and judges coverage across separate rules. The docs show the pair as two rules.

  • Rate-limit patterns were case-sensitive (#426, #432). URL conditions already ignored case, but a limit on /wp-login.php gave /WP-LOGIN.PHP its own budget on a case-insensitive filesystem. They now agree. The path key component is lower-cased too, and a lower-case path keys exactly as before.

  • The logged URL of a direct file had a trailing slash (#419, #431). The log line used getUri(), which gave /wp-login.php/, beside a block record that said /wp-login.php. Both now come from one helper.

Docs

  • configuration-keys.md listed challenge.cookie_name and header_name with a default of ''. They're fw_challenge_pass and X-Firewall-Challenge. On Pantheon, start cookie_name with STYXKEY_, or the edge strips it.
  • The mode: exception example now says the pass cookie's name must match challenge.cookie_name.

Full changelog: v2.34.1...v2.35.0