Decision records

ADR-0031: `.htaccess` compatibility (opt-in per application)

  • Status: Accepted (2026-10-06)

Context

Hosting customers bring applications written for Apache (WordPress, Laravel, Joomla, Drupal, …) whose routing, redirects and access rules live in .htaccess files they edit themselves. LiteSpeed and similar servers read them; without that, migrating a site means rewriting its rules by hand.

Decision

  • applications[].htaccess: true (default false) makes scalws read .htaccess files from the document root down to the requested directory. New crate scalws-htaccess: parser and evaluator, pure and unit-tested; the pipeline applies the result before the handler.
  • Supported (the subset common applications use):
    • mod_rewrite: RewriteEngine, RewriteBase, RewriteCond (server variables, %{HTTP:Header}, -f -d -s, =, <, >, regex, [NC,OR]), RewriteRule with [L,END,R=code,NC,QSA,QSD,NE,F,G,S=n,C]; $n and %n back-references.
    • mod_alias: Redirect, RedirectPermanent, RedirectTemp, RedirectMatch.
    • Access: Require all granted|denied, Require [not] ip …, legacy Order/Allow/Deny, inside <Files>/<FilesMatch> too.
    • ErrorDocument (local paths and URLs), DirectoryIndex, Header set|append|unset, ExpiresActive/ExpiresByType/ExpiresDefault, <IfModule> (true for the modules above; a block for another module is skipped, but access rules in it deny, as below), Options (no effect: scalws never lists directories).
  • Apache semantics kept: rewrite rules apply from the deepest .htaccess that has them; per-directory patterns match the path relative to that directory; an internal rewrite restarts rule processing (at most 10 rounds) unless [END]; other directives are merged, deeper overriding shallower. The rewritten path re-selects the route of the application; REQUEST_URI stays the original for PHP.
  • Security — the file is written by the tenant:
    • no proxy ([P]), no RewriteMap, no php_admin_*, no SetHandler/AddHandler: such lines are ignored and reported (logs, scalwsctl diagnostics);
    • regular expressions use the linear-time regex crate; patterns it cannot express (back-references inside patterns, look-around) make that rule inactive, not slow;
    • limits: 64 KiB per file, 1000 directives, 10 rewrite rounds; each regular expression compiles within 32 KiB, and at most 8 per file within 256 KiB (they run on the shared event loops); the parsed-file cache is weighed by compiled size (4 KiB for nearly every pattern, so long redirect lists stay cached) and by path length (64 MiB per app); an application may spend at most 100 ms per second parsing files: past that, a directory whose file is not cached denies until the next second, so the cost stays with that tenant; a request’s regular expressions, <FilesMatch> included, may scan at most 8 MiB in all rounds, each run costing 4 KiB on top of its subject (about 1 ms; past that the request answers 500); cached files are weighed by their source, warnings and stored items too; DirectoryIndex keeps at most 16 names; access rules past the directive or size limits deny (a <Files *> deny placed after the file’s own <Files> blocks), also below child directories that grant access;
    • a local ErrorDocument the deny rules block is not served: the plain error is (the path of its URI is checked, without query or fragment);
    • rewrites stay inside the application; -f/-d checks use the confined lookup of ADR-0006; .htaccess itself is never served (dotfiles are 404).
  • Cost: parsed files are cached per directory and re-checked (one statx) at most every 2 s, as LiteSpeed does; absent files are cached too.

Consequences

  • Unsupported directives are ignored with a warning instead of failing the site, except access rules: an access rule scalws cannot evaluate (HTTP authentication Auth* / Require valid-user|user|group|host|env, access rules inside <RequireAll>, <RequireAny>, <Limit>, <LimitExcept>, <If>, or a <Files>/<FilesMatch> with access rules and a pattern the regex engine cannot take) denies every request to that directory (or every name, for the <Files> block) with 403. Ignoring it would publish what the tenant meant to protect (security audit run-3 C8). The same holds for access rules inside <IfModule> for a module scalws does not model, after the 1000-directive limit, or in the part of a file beyond 64 KiB (run-4), and for <Files> blocks inside such containers, left open or never closed: they deny every name (run-5).
  • php_value/php_flag are not applied yet (FPM’s PHP_VALUE persists in the worker).