php.ini settings
; php.ini (or a conf.d/*.ini, or -d on the CLI)
extension=howdah.so
howdah.config=/etc/howdah/howdah.toml ; path to the TOML (optional)
howdah.mode=block ; block | detect | off (TOML can override)
howdah.log=/var/log/howdah.log ; where JSON event lines are written If howdah.config is unset, or the file is missing or malformed, Howdah uses its safe defaults (block mode, the limits below) and keeps serving.
Prefer not to hand-write the TOML? The config editor builds both files from a form, explains each setting next to its control, and validates the result with the extension's own parser, so a typo cannot silently turn into block mode on your server.
The TOML file
mode = "block" # block refuses, detect logs only, off disables
[limits]
# multipart body limits
max_parts = 2000
max_files = 200
max_part_headers = 16
max_part_header_bytes = 8192
max_field_name_bytes = 1024
max_filename_bytes = 512
max_parse_memory_bytes = 268435456 # 256 MiB
# input-shape limits
max_input_vars = 2000
max_input_nesting_depth = 64
max_input_key_bytes = 1024
# request-header limits
max_request_headers = 200
max_request_header_bytes = 16384
# [log] path is accepted but not used: set the log path with the howdah.log ini
[rules]
# override the mode for a single rule by name
max_request_headers = "detect"
self_test = "off" Any limit set to 0 is rejected (0 would block every request); there is no “unlimited”.
Limits and their defaults
| Directive | Default | What it bounds |
|---|---|---|
| max_parts | 2000 | parts in one multipart body |
| max_files | 200 | file parts in one body |
| max_part_headers | 16 | header lines in any one part (best-effort) |
| max_part_header_bytes | 8192 | bytes of any one part header (best-effort) |
| max_field_name_bytes | 1024 | bytes of a form-field name |
| max_filename_bytes | 512 | bytes of an uploaded filename |
| max_parse_memory_bytes | 268435456 | total body bytes processed (256 MiB) |
| max_input_vars | 2000 | GET+POST+cookie variables in one request |
| max_input_nesting_depth | 64 | array nesting of one name: a[b][c] = 2 |
| max_input_key_bytes | 1024 | bytes of any single variable name / array key |
| max_request_headers | 200 | request headers in one request |
| max_request_header_bytes | 16384 | bytes of any one request header |
Defaults are validated by the cross-version lab matrix and the false-positive corpus (zero-block at every value above). Rationale for each is in docs/config.md and the measurements in docs/coverage-data.md.
Layer 2 — input inspection ([inspect])
These rules only log; they do not block any request by default. Layer 2 scans decoded GET, POST, cookie and path values for SQL-injection, path-traversal and command-injection shapes. inspect.mode is independent of the global howdah.mode: a global howdah.mode = block does not arm Layer 2. To block, set inspect.mode (or one class under [inspect.rule_modes]) to "block" explicitly. Signature inspection is best-effort and can be bypassed, which is why it logs rather than blocks. Layer 2 adds no php.ini directives.
[inspect]
enabled = true # master on/off for Layer 2
mode = "detect" # detect = log only (default); block; off
sqli = true # per-class toggles
traversal = true
cmdi = true
[inspect.limits]
max_value_bytes = 16384 # scan at most this many bytes of one value
max_total_bytes = 2097152 # per-request aggregate scan budget
max_values = 2000 # per-request value count budget
[inspect.rule_modes] # optional per-class override of inspect.mode
# sql_injection = "block" # example only; also path_traversal, command_injection The [inspect] table lives in the TOML file named by howdah.config, not in howdah.ini. A [inspect.rule_modes] entry overrides inspect.mode for that class only. Values past a scan budget are not scanned. Events carry the rule, source and variable name, never the matched value. Full reference: docs/config.md.
Layer 3 — sink hardening ([sinks])
Sinks log by default; they do not block. Layer 3 watches dangerous PHP functions and writes an event when one is called with something your allowlist does not cover. It actively guards six sinks: command_exec (system, exec, shell_exec, passthru, popen, proc_open), unserialize, include/require and eval on every supported PHP version (include and eval on PHP 5.x are new in 1.3), plus phar and ssrf, new in 1.3 and off by default. sinks.mode is independent of the global howdah.mode: a global howdah.mode = block does not arm any sink. To block, set a sink's mode to "block" explicitly and set its allowlist first, because with an empty allowlist every call is not allowlisted. Layer 3 adds no php.ini directives.
[sinks]
enabled = true # master on/off for Layer 3
mode = "detect" # detect = log only (default); block; off
[sinks.command_exec] # system/exec/shell_exec/passthru/popen/proc_open
# mode = "block" # optional per-sink override; set allow FIRST
allow = [] # anything else is logged (or blocked in block mode)
[sinks.unserialize] # OFF by default: object unserialize is high-volume
# mode = "detect" # opt in (log only)
allow_classes = [] # class names that may be unserialized
[sinks.include] # include/require. Inert until allow_paths is set
allow_paths = [] # directories include/require may load from
[sinks.eval]
# New in 1.3. Both OFF by default; armed only by an explicit per-sink mode.
[sinks.phar] # phar:// guard
# mode = "detect" # opt in (log only); "block" denies
allow_paths = [] # realpath prefixes allowed even under block
[sinks.ssrf] # outbound requests to internal addresses
# mode = "detect" # opt in (log only); "block" denies
allow_hosts = [] # hostname | IP | CIDR; no wildcards The [sinks] table lives in the TOML file named by howdah.config, not in howdah.ini. command_exec allow patterns are anchored literal strings with an optional single trailing *, not regular expressions. The sink argument is never logged: events carry the sink and function name (and the class name for unserialize), never the command line or the serialized payload. include is inert until allow_paths is set, and blocking stops the include/eval from running. On PHP 5.x, include is checked at compile time, so a file served from a warm opcache is not re-inspected (eval is unaffected). [sinks.phar] and [sinks.ssrf] are off by default and are armed only by an explicit per-sink mode; [sinks] mode and howdah.mode do not arm them. phar.allow_paths is a list of realpath prefixes. On PHP 5.x/7.x the phar guard covers metadata deserialization through the stream wrapper; on PHP 8.x it is detection and denial only, with no deserialization claim, and the direct Phar API (new Phar, PharData, Phar::loadPhar, Phar::mapPhar, Phar::getMetadata) is not covered. ssrf.allow_hosts takes hostnames, IPs and CIDRs (wildcards and malformed CIDRs are rejected). SSRF is best-effort, resolve-at-check-time detection, not a connection-time boundary: DNS rebinding, redirects, curl_multi_*, CURLOPT_RESOLVE/CONNECT_TO and a non-string CURLOPT_URL bypass it, and copy, file, readfile, SoapClient, DOMDocument::load* and simplexml_load_file are not hooked. Enabling it adds a getaddrinfo per distinct outbound host with no timeout, so a DNS stall blocks the call. Known limitation: proc_open with an array command (PHP 7.4+) is not inspected. This is best-effort hardening and can be bypassed. Full reference: docs/config.md.
Rule modes and severity
Each [rules] key is a rule name set to block, detect, or off. Rules carry a severity:
Critical
max_parts, max_files, max_input_vars, max_input_nesting_depth, and the byte/memory limits. These block even when the global mode is detect — a detect-mode deployment still refuses the unambiguous resource-exhaustion shapes. Set a rule to off to fully disable it.
Standard
malformed_multipart, upload_progress_abuse, max_input_key_bytes, max_request_headers, max_request_header_bytes, self_test. These log in detect and block only in block mode — the shapes an unusual-but-legitimate client can hit.
Layer 2 adds sql_injection, path_traversal and command_injection, also severity Standard but log-only: they are governed by inspect.mode, not the global mode, so mode = "block" does not make them block.
A few byte-limit rule names end _len where the matching [limits] directive ends _bytes (for example the rule max_filename_len for the directive max_filename_bytes).
Enforcement timing (honest caveat)
The multipart limits refuse the request while the body is parsed, before PHP allocates per-part memory, and emit a 403/413 before the script runs.
The input-shape and request-header limits work on the request-variable and $_SERVER stream: Howdah drops the offending variables (bounding the symbol table — the real DoS mitigation) and writes the event, but the 403 response is best-effort and may be emitted at request shutdown, after the script produced output, on SAPIs that populate superglobals lazily (auto_globals_jit=On). Request-header counting depends on the app (or PHP) materializing $_SERVER. Treat the var-drop + log as the guarantee and the 403 as best-effort.
Log format
Howdah writes one JSON line per event, to a file or syslog. Each line carries a timestamp, request ID, rule ID, action, client IP, method, URI and the reason. The request ID also appears in the block response, so a user report maps to a log line.
{"ts":"2026-10-23T09:41:02Z","id":"91ab3f","rule":"max_files","action":"block","ip":"203.0.113.7","method":"POST","uri":"/upload.php","reason":"201 file parts > max_files 200"} Request bodies, cookies and authorization headers are never logged by default (privacy / GDPR).