Environment Variables in YAML¶
You can reference OS environment variables inside YAML using Symfony‑style tokens: %env(NAME)%.
- When a YAML scalar is exactly a single token (e.g.,
port: '%env(int:APP_PORT)%'), the value is returned as a native type based on the processor (int, float, bool, array, or string). - When a token appears inside a larger string, it is interpolated as text.
- Remember to quote tokens in YAML (e.g.,
' %env(...)% ') because%is a reserved indicator in YAML.
Variable Resolution with $_SERVER Fallback¶
The firewall checks environment variables in the following order:
getenv()- PHP environment variables (set viaputenv(), shell environment, or PHP-FPM/Apache configuration)$_SERVER- PHP superglobal (fallback whengetenv()returns false)
This fallback behavior is particularly useful in web contexts (Drupal, WordPress, Symfony, Laravel) where configuration is often stored in $_SERVER by the web server or application framework.
Example use case: Nested Array Keys
// In Drupal's settings.php, Pantheon sets database credentials in $_SERVER
$_SERVER['DB_SETTINGS'] = '{"databases":{"default":{"default":{"username":"db_user","password":"db_pass","host":"dbhost","port":"3306"}}}}';
# In firewall.yml, you can extract nested values from the JSON
storage:
type: "Kanopi\\Firewall\\Storage\\DatabaseStorage"
config:
connection:
# Extract values from nested JSON path: databases.default.default.*
# Each 'key:' processor navigates one level deeper into the JSON structure
user: '%env(json:key:databases:key:default:key:default:key:username:DB_SETTINGS)%'
password: '%env(json:key:databases:key:default:key:default:key:password:DB_SETTINGS)%'
host: '%env(json:key:databases:key:default:key:default:key:host:DB_SETTINGS)%'
port: '%env(json:key:databases:key:default:key:default:key:port:DB_SETTINGS)%'
Important: When extracting nested keys from JSON, you must chain key: processors for each level of nesting. For example, to access obj.a.b.c, use: json:key:a:key:b:key:c:VAR_NAME.
Priority: When a variable exists in both getenv() and $_SERVER, getenv() takes precedence. This allows you to override server-level configuration with environment-specific values.
Supported processors (can be chained left→right):
- Type Processors:
string,int,float,bool,json(→ array),base64,enum:FQCN(→ backed enum case, matched by value then by case name) - File Operations:
resolve(resolves relative paths), plus the opt-infileandrequireprocessors — see Filesystem Processors below - String Operations:
trim,lower,upper,urlencode,urldecode - Array/List Operations:
csv(→ list),query_string(→ array, preserves duplicate keys),url(→ array fromparse_url),shuffle(randomizes an array in place) - Special Processors:
default:value- Provides fallback value if variable doesn't existdefined- Returns boolean indicating if variable existsconst- Retrieves PHP constant instead of environment variablekey:name- Extracts a key from an array (chain multiple for nested keys)raw_key:name- Likekey:but does not treat the key name as further processors; use when a key contains a:not- Logical NOT (negates boolean value)safe:fallback- Wraps every processor to its right in a try/catch and returnsfallbackif any of them fail (missing variable, bad JSON, absent key). Useful for optional platform config — see Pantheon presets.
Examples
app:
# Basic type conversions
env: '%env(string:APP_ENV)%' # "dev"
port: '%env(int:APP_PORT)%' # 8080 (int)
debug: '%env(bool:APP_DEBUG)%' # true/false (bool)
options: '%env(json:APP_JSON)%' # { key: value } (array)
list: '%env(csv:ALLOWED)%' # ["a","b","c"]
params: '%env(query_string:QS)%' # { foo: "1", bar: ["2","3"] }
note: "running on %env(APP_ENV)%" # string interpolation
# Default values (fallback when variable doesn't exist)
environment: '%env(default:production:APP_ENV)%' # Use "production" if not set
max_size: '%env(int:default:100:MAX_SIZE)%' # Default to 100
enable_feature: '%env(bool:default:false:FEATURE_ENABLED)%' # Default to false
cache_dir: '%env(default:/tmp/cache:CACHE_DIR)%' # Default path
# Check if variable exists (in getenv() or $_SERVER)
has_config: '%env(defined:OPTIONAL_CONFIG)%' # true/false (bool)
# Use PHP constants
cache_path: '%env(const:KANOPI_FIREWALL_CACHE_DIR)%' # From define()
# Nested JSON key extraction
db_host: '%env(json:key:database:key:host:CONFIG_JSON)%'
# Backed enum resolution — resolves to an enum *instance*, so only use it
# for keys read by your own code (e.g. a custom plugin's metadata).
tier: '%env(enum:App\Enum\ServiceTier:SERVICE_TIER)%' # 'gold' or 'Gold' → ServiceTier::Gold
# Tolerate a missing / malformed variable
db_name: '%env(safe:fallback_db:json:key:name:DB_SETTINGS)%' # "fallback_db" on any failure
Filesystem Processors (opt-in)¶
The file: (read a file's contents) and require: (include a PHP file and use its return value) processors are disabled by default. Their path typically comes from an environment variable, and where it does, enabling them turns any env-var injection into a local file inclusion — or, for require:, remote code execution.
The path does not have to come from a variable — see Reading from a known path below, which is the lower-risk form when you already know where the file is.
Using either one without opting in raises ConfigurationException from TokenSubstitute. Note where that exception ends up:
- Calling
TokenSubstitute::substitute()directly — the exception propagates to you. - A token inside a YAML config —
Config::loadFile()catches it and drops that file from the merge, so with the defaultrequire_config: falsethe firewall starts with a config missing those rules — potentially an empty one that allows every request. The failure is logged aterrorlevel with the reason. Setglobal.require_config: trueto make it a startup failure instead.
Opt in during bootstrap, before any config is loaded, and constrain the reads to directories you control:
use Kanopi\Firewall\Utility\TokenSubstitute;
// Allow file: reads, but only from within /etc/firewall/secrets.
TokenSubstitute::enableUnsafeProcessors(['file'], ['/etc/firewall/secrets']);
\Kanopi\Firewall\Firewall::create([__DIR__ . '/firewall.yml'])->evaluate();
- First argument — processors to enable. Only
fileandrequireare valid; anything else throwsConfigurationException. - Second argument — absolute base directories. The resolved
realpath()of the target must sit under one of them, otherwise loading fails. Passing an empty list disables the prefix check entirely and is not recommended in production — do it only if you have vetted every path your environment variables can produce. - Base directories must already exist; a directory that does not resolve throws
ConfigurationException. TokenSubstitute::resetUnsafeProcessors()clears the opt-in again. It exists for test suites, not for request-time use.
Reading from a known path¶
When you already know the path, use %file(...)% rather than an environment variable:
challenge:
secret: '%file(/etc/firewall/hmac.key)%'
global:
banning_message: '%file(/etc/firewall/banned.html)%'
The token content is the whole path, so unlike the file: processor there is no colon limitation — %file(/tmp/sec:rets/key.txt)% works. There is also no environment variable involved, so nothing in the environment can redirect the read.
The same opt-in and base-directory allowlist apply:
A literal path in a config file is only as trustworthy as that file, so this form is lower risk than the env-var one — but it is held to the same controls so there is a single mental model, and the allowlist still limits the blast radius if a config file is ever compromised.
Contents come back verbatim, newline included, exactly as file: returns them. A key file written by an editor usually ends with \n, and an HMAC secret carrying a stray newline fails in a way that is annoying to diagnose — if that matters, read it through a configuration override where you can trim() it.
%file(...)% interpolates inside a larger string and resolves inside nested arrays, like %env(...)%.
The older workaround, and why to move off it
Before this token existed, the only way to use a literal path was to chain default: into file::
It still works, and you will meet it in existing configs, but it carries two hazards that %file(...)% does not.
UNUSED must never be defined by anything. Define it — a platform injecting variables, a .env file, a colleague reusing the name — and the path silently becomes that value. Nothing in the config file signals the dependency. This is where the base-directory allowlist earns its keep: with enableUnsafeProcessors(['file'], ['/etc/firewall']) in place, a hijacked variable pointing at /etc/passwd fails loudly rather than being read:
Never pass an empty allowlist when using that form.
A colon truncates the path. Tokens are split on :, so %env(file:default:/tmp/sec:rets/key.txt:UNUSED)% resolves the path as /tmp/sec. This is the same limitation raw_key: exists to work around for keys; there is no equivalent escape for paths.
Prefer file: over require: whenever you can — reading a secret is far less dangerous than executing a path an attacker may influence.
Path resolution for common keys
Some metadata values are commonly file paths. The loader automatically rewrites relative values to absolute when they exist on disk, using the YAML file's directory as the base. You can target keys with dot‑path patterns and lightweight alternation:
*matches any key at that level- Alternation per segment:
block|allow,{block,allow}, or(block|allow)
Useful patterns
logger.*.args.0
# New plugins: array format
plugins.*.metadata.reader.db
plugins.*.metadata.storage.config.file
plugins.*.metadata.config.*
# Legacy block:/bypass: format (still supported)
(block|allow).Kanopi\Firewall\Plugins\GeoLocation.metadata.reader.db
(block|allow).Kanopi\Firewall\Plugins\Asn.metadata.reader.db
(block|allow).Kanopi\Firewall\Plugins\RateLimit.metadata.storage.config.file
With these patterns, paths like logs/app.log, geo/GeoLite2-ASN.mmdb, or limits/rate.yml will be resolved relative to the YAML file and stored as absolute paths at runtime.