PHX Security Audit Report (v0.2)
This report consolidates three audits. §1–§6 cover the first audit (and the resolution of its residual risks), §7 covers the second vulnerability assessment,
and §8 covers the parts excluded from the second assessment (phx lsp, phx migrate, bytecode cache).
Date: 2026-11-02 Scope: All crates of the PHX implementation (parser, type checker, compiler, VM, JIT, standard library, web server, CLI tools)
1. Method
- Three independent reviews. To avoid inheriting the implementer's assumptions, three reviewers with separate areas read the source
and confirmed each issue by running actual attack code (PoC).
- A: HTTP layer (routing, static files, sessions, CSRF, multipart, headers)
- B: Runtime (VM, JIT, memory and time limits, standard library, file permissions, SQLite)
- C: Language guarantees and tools (escaping, type-system soundness,
Html/Sqltypes, module loading,phx install/migrate/lsp)
- Fixes and re-verification. All findings were fixed, and the reviewers' PoCs were rerun against the fixed build.
- Automated tests. Regression tests (
tests/cases/err_security.phx,security.phx) were added to the golden tests. In addition, HTTP integration tests against a real server and fuzzing with mutated programs (about 22,000 cases locally) run on every CI build.
2. Summary of Results
| Severity | Count | Fix |
|---|---|---|
| Critical | 3 | All fixed |
| High | 9 | All fixed |
| Medium | 10 | All fixed |
| Low / Info | 18 | All fixed (2 initially accepted, then fixed in the follow-up in §4) |
| Found during follow-up | 1 | Fixed (N1 in §4, Medium) |
All Critical and High findings were fixed, and PoCs verified that they no longer reproduce. The items listed as "residual risks" in the initial report were also resolved in the follow-up in §4, except those accepted by design (§5).
3. Findings and Fixes
Critical
| # | Finding | Fix | Verified |
|---|---|---|---|
| C1 | On Windows, .phx sources, templates, and dotfiles were served verbatim via aliases such as /INDEX.PHX, /index.phx::$DATA, and 8.3 short names (/DASHBO~1.PHX, /ENV~1) |
Serve only when every component of the normalized path has exactly the same spelling as the requested path. Reject paths containing :. Never treat .phx / .phx.html / files starting with . as static files |
All patterns return 404 on a real server; added to unit tests |
| C2 | Dropping a list nested 300,000 levels deep overflowed the stack during recursive deallocation and crashed the whole server process (catch_unwind cannot stop it) |
Made Value deallocation and == comparison iterative |
Completes normally at 1,000,000 levels (test deep_values_do_not_overflow_the_stack) |
| C3 | A failed huge allocation, such as "a"->padStart(10^13, "b"), aborted the process |
Built-ins whose output can exceed their input check the limit with reserve() before allocating (pad, repeat, replace, chars, split, join, concat, range, file reads, string concatenation) |
Raises an uncatchable fatal error (memory limit); the process stays alive |
High
| # | Finding | Fix | Verified |
|---|---|---|---|
| H1 | Narrowing a generic type argument with is produced never, letting any value pass as Sql or Html (SQL injection, XSS without raw()) |
Narrowing a type argument or any yields the tested type. Narrowing accounts for interfaces and Sql, and is not applied when the result would be never. Html concatenation also escapes plain strings at runtime |
PoC fails to compile |
| H2 | Property narrowing persisted even when a call occurred on the right side of && / || or inside render() |
Advance a generation number on every call and discard property facts across calls | All 4 PoCs fail to compile |
| H3 | .php and similar files under public/ were served raw |
Serve only known static extensions (css, js, images, fonts, etc.) | /config.php returns 404 |
| H4 | Anonymous GETs created server-side sessions for CSRF tokens; at 100,000 sessions the store filled up, and new visitors could not log in or submit forms | Make the CSRF token an HMAC of a random per-browser cookie, keeping no server-side state. The session store has count and byte limits and evicts the oldest entries first | Unit test (new visitors still get a session past the limit) |
| H5 | No timeout for receiving headers and body (slowloris), and no connection limit | Headers 10 s (including keep-alive idle time), body 30 s, 1024 concurrent connections | Partial headers are cut off after 10 s |
| H6 | The memory limit was mostly ineffective inside built-ins | Check on every built-in call, reserve() before allocating, SQLite string-length and heap limits |
With a 16 MB limit, a PoC allocating 1 GB is stopped |
| H7 | SQLite queries ignored the execution time limit | Abort at the deadline via SQLite's progress handler | An infinite recursive CTE stops after 2 s |
| H8 | The execution time limit counted instructions, so repeating slow built-ins stretched it many times over | Check the clock on every built-in call | |
| H9 | A dangling symbolic link (target does not exist) placed in a writable directory allowed writing files outside the sandbox | Reject symbolic links and reparse points (junctions), and operate on the resolved path | Both links and junctions are rejected |
Medium
| # | Finding | Fix |
|---|---|---|
| M1 | Escaping ignored context (unquoted attributes, href="javascript:...", <script>, <style>, event attributes) |
Determine the HTML context of each <?= ?> at parse time, and make inserting a string into an unsafe location a compile error. Added safe_url() and json_html() |
| M2 | response()->type("image/svg+xml") or "text/plain, text/html" disabled escaping |
Escaping is disabled only for allowlisted types (text/plain, application/json, text/csv, etc.). Reject Content-Type values containing commas |
| M3 | render() could read any file in the project, such as .env. Junctions also allowed reading outside the project |
Templates must be .phx.html. Verify that the normalized path is inside the project or a package |
| M4 | phx install ran git inside an existing vendor/<name>/.git, executing bundled hooks |
Always clone fresh into a temporary directory, disable hooks and fsmonitor, and restrict protocols with an allowlist. Lock-file commits accept only 40-digit hex |
| M5 | phx migrate commented out require 'auth.php' and reported "passes type check" (auth checks silently disappear) |
Emit an uncompilable TODO_migrate(...) call instead. Overwriting existing files requires --force |
| M6 | Scripts could set Content-Length and similar headers, desynchronizing keep-alive responses (response splitting) |
Reject framing-related headers in both built-ins and the server |
| M7 | Development mode (detailed error display) was the default | Development mode is the default only when listening on loopback. Can be set explicitly with --dev / --prod |
| M8 | SQLite PRAGMA temp_store_directory and similar could change process-wide settings or write to other directories |
Reject ATTACH/DETACH and dangerous pragmas via an authorizer |
| M9 | Out-of-range slice() caused an internal panic |
Use saturating arithmetic. Check integer overflow in release builds too |
| M10 | Uploads could be saved as .phx, and write = ["."] allowed rewriting source |
.phx cannot be written. Paths under public/ and the project root cannot be listed in [permissions].write |
Low / Info (main items)
- Upload filenames: Strip
:, Windows reserved names, and trailing dots (fixed). - Sessions: Login (
regenerate()) also rotates the CSRF cookie; 24-hour absolute expiry;__Host-prefix and HSTS over HTTPS. Responses with a session getCache-Control: private, no-store(fixed). - Redirects:
redirect()accepts only same-site paths. External redirects useredirectExternal()(open-redirect defense). - Path dependencies: Absolute and UNC paths are not allowed. This prevents SMB connections triggered just by opening a project (fixed).
- Bytecode cache: The JIT validates register, jump, and call ranges in loaded bytecode (fixed). The cache itself is treated as having the same trust level as source code (accepted by design).
password_verify: Added a limit on the cost parameters of stored hashes (fixed).- Other: Files starting with
_are not exposed as pages. Status 1xx is rejected. JSON\uescapes are parsed strictly. LSP header length is limited, and UNC URIs are rejected. All fixed.
4. Follow-up Fixes (Resolving Residual Risks)
Items accepted in the initial report were revisited and fixed.
| # | Previous risk | Fix | Verified |
|---|---|---|---|
| R1 | A hard link to an external file placed in a writable directory allowed reading and writing that file | Check the link count on the opened file handle, and reject unless it is 1 (for both reads and writes). Also reject non-regular files (directories, FIFOs, devices) | Reads and writes via a hard link to a real file raise PermissionError; the outside file is unchanged |
| R2 | A directory could be swapped for a link between the path check and the open (TOCTOU) | New safefs module. Opens one component at a time from the allowed directory's handle, rejecting links. Unix uses openat + O_NOFOLLOW; Windows opens with FILE_FLAG_OPEN_REPARSE_POINT and keeps each directory open without FILE_SHARE_DELETE, preventing rename, deletion, or swapping while in use. Writes open without truncation, check, then truncate |
Windows: all access via junctions and symbolic links rejected; a unit test confirms a directory cannot be renamed while its handle is held. Linux (Docker): tests reject symbolic links, FIFOs, and o+w directories |
| R2' | SQLite opens files by name, so the R2 approach cannot be used as is | Pin the directory by keeping it open, and before and after opening verify that the database and -journal / -wal / -shm are "absent, or regular files without links". Pass SQLITE_OPEN_NOFOLLOW. On Unix, reject directories writable by other users (o+w) |
Database::open via a junction is rejected |
| N1 | (Found in this round, Medium) file_put_contents("data/a.phx::$DATA", ...) and "data/b.phx." bypassed the .phx write ban and created a.phx. Device names such as CON were also usable |
For each path component, reject :, trailing dots and spaces, device names such as CON NUL COM1 LPT1, and control characters (same rules on all OSes) |
All 3 PoCs raise PermissionError |
| R3 | CSP was off by default | Enabled by default. The parser records the position of each <script> tag written in a template, and a per-response nonce is added automatically. A <script> injected via raw() or similar gets no nonce, so it does not run |
HTTP integration test confirms the header nonce matches the template's two <script> tags and is absent from the raw() <script>. Sample site works in a browser |
| R4 | No rate limiting | Added rate_limit($key, $max, $seconds) / rate_limit_reset($key) and request()->ip. Site-wide limit [security] requests_per_minute (429 with Retry-After when exceeded). The counter table holds up to 100,000 keys and discards the oldest windows first. Used in the sample guestbook |
Unit tests and HTTP integration tests |
| R5 | Sessions were memory-only | [security] session_store = "dir/" stores them in files. Filenames are the SHA-256 of the ID (IDs cannot be learned from a listing); writes are atomic via rename from a temp file; reads and writes go through safefs. Count and size limits, plus cleanup of expired sessions |
HTTP integration test confirms another server process can read the same session |
5. Residual Risks
| Item | Description | Decision |
|---|---|---|
| Context detection is approximate | Context detection for <?= ?> is based on source order. HTML built dynamically by concatenating Html values is not analyzed |
Html is the type for "trusted HTML", and only raw(), escape(), and templates can create it. The spec states that raw() content is the user's responsibility. The default CSP adds a second layer against execution of injected scripts |
| SQLite auxiliary files (Windows) | A very small window remains in which a symbolic link could be created under a journal file's name between the pre-open and post-open checks | On Windows, creating file symbolic links requires admin rights or Developer Mode, and the directory itself cannot be swapped. Dedicate the [permissions] directories to script data |
| Rate limiting is per process | Counters are not shared across multiple server processes | Future work. The spec notes that behind a reverse proxy, request()->ip is the proxy's address |
6. Ongoing Verification
cargo test --workspace: golden tests (both interpreter and JIT), security regression tests, HTTP integration tests against a real server (aliases, traversal, CSRF, cookie attributes, time limits), and fuzzing.- CI: Linux / Windows / macOS,
clippy -D warnings, long-running fuzzing,cargo audit(known vulnerabilities in dependency crates). - Unix-specific code (the
openatpath insafefs) was also verified by running all tests and clippy in a Linux Docker container. unsafeis used in only three places: the memory-accounting allocator (phx_vm/src/alloc.rs), calls into JIT-generated code (phx_jit,Vm::call_jit), and calls to OS file APIs (phx_std/src/safefs.rs).
7. Second Vulnerability Assessment
Date: 2026-11-02 (after the first round of fixes and the resolution of residual risks)
Method
To avoid anchoring on the first audit's conclusions, four new reviewers were given only the source, and each verified issues by actually attacking.
- A: Code written after the first audit (
safefs, file-backed sessions, rate limiting, CSP nonces) - B: Web server and sample site (external attacks)
- C: Template context detection and the type system
- D: SQL, runtime limits, JIT, bytecode cache, tools
Dependency crates were also checked with cargo audit (0 known vulnerabilities; the unmaintained atomic-polyfill was removed
by disabling postcard's default features). The unsafe code and the CI configuration were also reviewed. For fix verification,
the reviewers' PoCs were rerun against the fixed build and added to the regression tests. All tests were also run on Linux (Docker).
Summary of Results
| Severity | Count | Fix |
|---|---|---|
| High | 6 | All fixed |
| Medium | 9 | All fixed |
| Low / Info | 11 | 9 fixed, 2 documented |
High
| # | Finding | Fix |
|---|---|---|
| R2-1 | Results of escape(), htmlspecialchars(), and nl2br() (Html) could be placed at the start of href or inside <script>. HTML escaping stops neither javascript:alert(1) nor script code, and template <script> tags get a nonce, so CSP did not block it either |
Added context-verified types SafeUrl (safe_url()) and SafeJs (json_html()). URL starts accept only SafeUrl and numbers; <script> contents accept only SafeJs and numbers. json_html() values are rejected inside attribute values (a " would end the attribute) |
| R2-2 | echo written between template HTML (<?phx echo $x; ?>) was not context-checked |
echo gets the same checks as <?= ?> |
| R2-3 | file_put_contents("views/page.phx.html/", ...) (trailing /, /., \) bypassed the .phx write ban, allowing templates to be rewritten for code execution. unlink and Database::open did not check the ban |
Check the final filename after path normalization, and apply it to all writes, deletes, saves, and databases. Reject paths ending in / \ /. |
| R2-4 | Windows 8.3 short names (views/GREETI~1.HTM) allowed rewriting .phx.html templates |
Retrieve the real name from the opened handle, and reject if it differs from the requested name (8.3 short names and other aliases). Also applied to directories |
| R2-5 | join(), json_encode(), json_html(), dump(), echo, and string concatenation did not check the memory limit before allocating; a single call could allocate several GB and crash the process (serve also stopped) |
Compute the result size first and reserve() (join); once output exceeds 64 KB, proceed while checking the memory and time limits (JSON, dump); echo and concatenation check before allocating, including the up-to-6x growth from escaping |
| R2-6 | == on shared structures ($a = [$a, $a] 60 times) took exponential time, ignored the time limit, and tied up a worker |
Remember already-compared list and map pairs, and compare each pair once (linear time). Quadratic operations such as unique() check the time limit as they run |
Medium
| # | Finding | Fix |
|---|---|---|
| R2-7 | Template context detection differed from browsers: treating </scripts> as an end tag, <!--<script> inside <script>, <!--> and --!>, <textarea> and <title> contents, inside <svg>, srcdoc, and shifting the URL-start detection with characters, as in href=" ..." or href="j<?= ?>" |
Rewrote to match WHATWG tokenization (end-tag delimiters, script escaped / double escaped states, comments and bogus comments, RCDATA / RAWTEXT elements, foreign content). A URL position counts as the start until / ? # : appears in the preceding literal text. Strings are rejected in javascript: URLs and srcdoc |
| R2-8 | Opening <script> inside a function or loop without closing it shifted the context depending on call order |
Blocks and function bodies must end in the same HTML state they began in (functions start from an empty document) |
| R2-9 | <script<?= $x ?>> allowed insertion inside a tag name, and the attacker's src= received a nonce (CSP bypass) |
Treat the middle of a tag name as inside the tag, making it an error |
| R2-10 | Objects with reference cycles ($n->next = $n) were not freed after the request, so server memory kept growing |
The VM tracks the objects it creates via weak references, and clears their fields at the end of execution to break cycles |
| R2-11 | Deeply nested source (50,000 levels of 1+1+..., if, [[[...]]], etc.) overflowed the stack in the parser, type checker, and compiler (uncatchable, so serve also stopped). Error output quoted the 50,000-character line every time, producing 8.5 GB of output |
Nesting is limited to 500 levels (beyond that, parsing stops with a single error). Errors are capped at 50; long lines show only the area around the error |
| R2-12 | redirect("/<TAB>/evil.com") passed as a same-site path; browsers stripped the tab and navigated to //evil.com (open redirect) |
Reject paths containing control characters, whitespace, or \. redirectExternal() also rejects control characters |
| R2-13 | session_store = "." or "data/" was allowed; expiry cleanup deleted project files and overwrote .gitignore with *. Overlapping with a script permission directory allowed forging or reading sessions |
Allow only a dedicated session directory (not the root, public/, or anything overlapping [permissions]). Cleanup deletes only files named like session files. .gitignore is created only if absent |
| R2-14 | Right after logout or regenerate(), a concurrent request that saved the session resurrected it |
Skip saving if the original session is gone at save time (both memory and file stores) |
| R2-15 | SQLite memory was not counted toward the script limit; :memory: databases or cache_size let one request use hundreds of MB |
Set the page cache to 2 MB per connection, and set max_page_count for :memory: to match the limit. Reject the cache_size, temp_store, and max_page_count pragmas |
Low / Info
- Rate limiting (fixed): 100,000 keys could evict keys currently being limited → discard keys that have not hit the limit first. Changing the window for the same key reset the count → count per window. IPv6 is grouped by /64.
- Configuration (fixed):
write = ["public."]or["..."]pointed topublic/or the root on Windows → apply the same checks as for filenames to each path component. A hugesession_ttloverflowed → range-checked. - 5 or more type parameters (fixed): The type checker panicked → out-of-range parameters are treated as unbound.
mktime/checkdate(fixed): Out-of-range values silently wrapped around →ValueError.password_verify(fixed): Lowered the cost limits for stored hashes to 64 MiB, 8 iterations, and parallelism 4.- Session files (fixed): Readable by other users on Unix → created with 0600. Mass writes between the 30-second cleanups → clean up early based on write volume.
phx fmt/phx test(fixed): Directory traversal followed symbolic links and junctions, which could loop forever or rewrite outside files → links are not followed.- CI (fixed): Third-party GitHub Actions were pinned by tag → use only GitHub's own actions plus rustup and
cargo audit; checkout credentials are not persisted. - CSP nonce (fixed):
<scriptstrings inside<textarea>also got a nonce → RCDATA element contents are excluded. The documented'nonce-{nonce}'syntax now works too. - Content-Type values that disable escaping (documented): Some types (e.g.
text/markdown) were missing from the spec. Browsers render none of them as HTML, so the spec was updated to match the implementation. - CSRF (documented): The cookie-HMAC scheme assumes cookies cannot be tampered with. The spec now states that production must use HTTPS and
secure_cookies = true.
No Issues Found (main items)
Path traversal and source exposure (encoding, case, 8.3 short names, ADS, trailing dots), response splitting, CSRF bypasses,
cookie attributes, uploads, production-mode error display, slowloris, huge multipart, deep JSON, sql"..."
parameterization (no way to construct an Sql value through the type system was found), the SQLite authorizer, JIT/interpreter result parity and
limits, and the phx install git invocation (-- separator, revision character set, protocol allowlist).
Not Covered in This Round
phx lsp, phx migrate, and the bytecode cache were out of scope (assessed in §8).
8. Third Assessment (lsp, migrate, cache)
Date: 2026-11-02. One new reviewer verified, with PoCs, the three areas excluded from the second assessment. The item left over from the second
assessment, forging connection handles via new Database(<integer>), was also closed.
| # | Severity | Finding | Fix |
|---|---|---|---|
| R3-1 | High | The premise that the cache has the same trust level as source did not hold. Entries were not checked against the script they belong to (always valid when the dependency list was empty), and the stamp (version, executable size and mtime) is public information, so a .phx-cache/ in a project received from someone else could hold bytecode that differs from the source (code other than the source you read runs) |
Sign with an HMAC using a per-user secret key (outside the project; 0600 on Unix), and refuse entries whose signature does not match. Record the canonical paths of the script and the root in each entry and check them, and verify that the script itself is among the dependencies |
| R3-2 | Medium | Dependencies were compared by mtime only, so mtime-preserving copies (cp -p, rsync -t, archive extraction) or same-length rewrites kept stale bytecode running (deployed security fixes would not take effect). There was also a race where a file changed between reading it and fetching its mtime |
Record the SHA-256 of the compiled contents, the size, and the mtime of each dependency. Always verify contents when loading the cache; the server's in-memory check also verifies the contents of files modified just before recording (within the timestamp resolution) |
| R3-3 | Medium | Cache writes followed links; a project with .phx-cache as a junction, or a symbolic-linked .gitignore, allowed overwriting files outside the project. Temp file names were also predictable |
Read and write via safefs, rejecting links and hard links. Do not use .phx-cache if it is itself a link. Temp files get random names; .gitignore is created only if absent |
| R3-4 | Low | [permissions].write could list .phx-cache/ or vendor/, letting scripts write files that are later loaded as code |
Directories starting with ., vendor/, and package directories cannot be listed in write |
| R3-5 | Medium | phx migrate did not convert <?PHP / <?Php (uppercase), so PHP containing the auth require and exit remained as a plain string, and it reported "passes type check" (auth checks silently disappear) |
Convert open tags case-insensitively. Keep any <? that cannot be converted, with an uncompilable TODO_migrate(...) attached |
| R3-6 | Medium | phx migrate output followed dangling symbolic links and was written to arbitrary locations. Varying the spelling, as in self.PHX, allowed overwriting the input file itself |
Write via safefs, never to links or hard links. Compare input and output by normalized path |
| R3-7 | Low | phx migrate panicked on non-ASCII characters (e.g. Japanese) near comments or strings |
Compare byte-wise |
| R3-8 | Low | phx lsp URI checks could be bypassed with decoded UNC paths (file:///%5C%5Chost%5Cshare) or \\?\ and \\.\, allowing reads of unopened files, triggering SMB connections, and hanging when given a named pipe |
After decoding, accept only local paths that start with a drive letter; reject device names and ... Hover works only on open documents. Up to 1000 open documents. Return an error on malformed JSON and continue |
| R3-9 | Info | new Database(0) could forge a connection handle (within the same request only) |
new now checks private / protected constructors (previously unchecked), and the Database constructor is now private |
No Issues Found
phx lsp does not write files. It has limits of 64 MiB per body and 8 KiB per header line, and resists deep nesting (the parser's 500-level
limit). phx migrate leaves require, exit, eval, $$var, extract, superglobals, etc. as
uncompilable code. Decoding the cache format (postcard) is safe; even with adversarial bytecode,
the interpreter stops with a bounds-check panic (caught by catch_unwind), and the JIT rejects it via the well_formed check.
Verification
All reviewer PoCs were rerun against the fixed build and verified to no longer reproduce. Regression tests were added for cache forgery, reuse,
same-length rewrites, junctions, constructor visibility, lsp URIs, and config validation.