The textbook example of remote file inclusion is a PHP include() line, yet PHP ships with remote includes switched off: allow_url_include defaults to 0 and has been deprecated since PHP 7.4.0. The version of this bug class that matters in a Node.js or Python app is path traversal: an endpoint that builds a file path from a name the user controls.

Remote file inclusion: what it is, and why it is rare outside old PHP

Remote file inclusion is a bug where an app loads and runs code from a URL a user supplied, because request input reached a function that includes files. It needs a language that can include a URL, which in practice means PHP, where the setting that allows it defaults to off and has been deprecated since PHP 7.4.0.

File handling is one strand of web app security for an AI-built app, and this is the strand with the oldest textbook.

The PHP side is short. In the manual’s words, allow_url_include “allows the use of URL-aware fopen wrappers with the following functions: include, include_once, require, require_once”, and it “requires allow_url_fopen to be on”. So, in my reading, a PHP app is open to remote inclusion when someone turned the setting on and some include takes request input. To prevent RFI, leave allow_url_include at its default of “0” and never pass request input to include or require; where a name must pick the file, use the allowlist in the five fixes below. PHP’s filesystem configuration options show both settings and their defaults.

Node.js and Python have no such switch. Node’s module docs say “each file is treated as a separate module”, and a required name that starts with a slash “is an absolute path to the file”. Python’s import path “is a list of locations that may name file system paths or zip files”, and the default finders “implement all the semantics for finding modules on the file system”. The docs add that the import path “can also be extended to search for any locatable resource, such as those identified by URLs”. I read that as an extension someone adds on purpose, and as the reason textbook RFI is rare in these stacks.

What does turn up is the same mistake in other shapes. A server that fetches a URL the user supplied is server-side request forgery, and SSRF protection and the other request-trust bugs covers it. A page that loads a script from a host it does not control is a transport and integrity problem; that side is a separate question: what stops a man-in-the-middle attack. “File injection”, as people search it, covers both inclusion and hostile uploads; the upload half comes up again in the five fixes. A file inclusion vulnerability of either kind is an input validation failure at one specific sink: a file API.

LFI meaning, RFI and path traversal: the names you will see in a report

LFI means local file inclusion: user input chooses a file already on the server and the app executes or renders it. RFI is the same with a file from another server. Path traversal only reads or writes the file. MITRE files the first two under CWE-98 and path traversal under CWE-22.

A report or scanner may print any of these names, and some write “local file include” for the same thing, the way MITRE lists “Remote file include” beside RFI. The table maps each name to its MITRE entry.

Name in the reportAbbreviationCWE idWhat the user controlsWhat happens to the fileWorst outcome
Remote file inclusionRFICWE-98A URL that reaches an includeFetched from another server and executedThe attacker’s code runs on your server
Local file inclusionLFICWE-98A name or path that reaches an include or template callAlready on the server; executed or renderedCode runs if a file the attacker wrote can be included
Path traversal, also directory traversal or dot-dot-slashNoneCWE-22 (children: CWE-23 relative, CWE-36 absolute)A name or path that reaches a read, write or delete callRead, written or deleted, not executedSecrets read; code or templates replaced on a write
External control of file name or pathNoneCWE-73Any part of a file name or pathDepends on the call it reachesCan precede CWE-22 or CWE-98

The “what the user controls” and “worst outcome” columns are my reading; the ids, names and relationships are MITRE’s. CWE-98 is titled “Improper Control of Filename for Include/Require Statement in PHP Program (‘PHP Remote File Inclusion’)” and lists “Remote file include”, “RFI” and “Local file inclusion” as alternate terms. CWE-22 is “Improper Limitation of a Pathname to a Restricted Directory (‘Path Traversal’)”; its parent is CWE-706, “Use of Incorrectly-Resolved Name or Reference”, and its children are CWE-23 “Relative Path Traversal” and CWE-36 “Absolute Path Traversal”. CWE-73 is not a parent of either: MITRE lists it as a weakness that can precede CWE-22 and CWE-98. If a scanner prints CWE-73 where you expected path traversal, read it as “user input reaches a file path” and confirm which of the two follows.

Set against its neighbors, local file inclusion differs in two ways. The difference between LFI and RFI is where the file lives: on your server, or on someone else’s. The difference between LFI and path traversal attacks is whether the file is executed or only read, and traversal is usually how an LFI reaches a file outside its folder, in my reading. MITRE’s note on the “Local file inclusion” term points the same way: the term is “frequently used” when “the first part of the filename is not under the attacker’s control, which forces use of relative path traversal (CWE-23) attack techniques”.

Reports and scanners label the same finding a local file inclusion vulnerability, an LFI vulnerability, an LFI attack or a local file inclusion attack. A traversal attack also goes by “directory attack” in search, though that phrase also covers Active Directory attacks and directory harvest attacks, which are other subjects. The letters “LFI” also name a French political party, among other things; here they mean local file inclusion only. Script injected into a page is the neighboring class, and how to mitigate cross-site scripting covers it.

Why it matters for an AI-built app: where a path traversal vulnerability hides

A path traversal vulnerability needs two things: a request field that names a file, and code that hands that name to a file API. The six places below are where I’d look first in an AI-built app; the list is my reading of where the pattern appears, not a count from audits.

Endpoint patternThe input that reaches the pathThe file API behind itWhat a read or write exposes
A download or “view file” route, such as /api/files?name=The name query parameterA file read or a send-file helperAny file the app process can read
An export or report route with a temp folderA file name the user choseA write to the temp folder, then a read backA write outside the folder, then a read of anything
An image resize or document convert routeThe upload’s original filenameThe image or document library’s open callReads outside the upload folder
A template, locale or theme loaderlang, template or theme from the query stringThe view engine’s render or include callLocal file inclusion: a file rendered or run as a template
An import route that extracts a zip or tar archiveEach entry name inside the archiveThe archive library’s extract callFiles written outside the extraction folder
A hand-written static file handlerThe URL path itselfA stream or open call on the joined pathThe source code and anything beside it

The archive row has a public name. Wikipedia calls it “the “Zip Slip” vulnerability that affects several archive file formats like ZIP”, and Snyk’s security team responsibly disclosed it ahead of a public disclosure on 5 June 2018.

Why AI builders write these, in my reading: the prompt said “let the user download their file”, and the shortest code that does it joins a folder and a request parameter. Take a founder who asks an AI builder for a “download your invoice” button, where the route it writes joins the invoices folder and the file name from the query string with Node’s path.join before reading the file. A reviewer reading that route against Node’s docs sees that the join normalizes its result and resolves parent-directory segments, so the name, not the code, decides which folder the file comes from. The fix is the first of the five below: the button sends an invoice id, and the server looks up the stored path for a row the user owns. The lesson I take from it is that a file name from a request is a path until the code proves it is not.

What a read exposes depends on the host. On a managed host the platform injects secrets as environment variables, so in my reading the files that matter are a committed .env, a service-account JSON file, a SQLite database file, other tenants’ uploads and the source code itself. Path traversal vulnerabilities that write are worse than ones that read, because a write can replace code or a template.

RCE in a report means remote code execution: someone runs their own commands on your server. The step from a directory traversal vulnerability to an RCE hack is a chain, not a single trick: a read leaks an admin key that opens a feature which runs code, a write replaces a template the app renders later, or an include executes a file the attacker managed to place. In my reading, that chain is why cyber security reports can rate a path traversal attack as RCE even when the first bug only read a file.

In my June and July 2026 audits, the Input, Injection & Abuse pillar averages 61.9 out of 100 across the third-party apps I audited, scored on 21 of the 21 third-party apps. Those 21 are 11 public vibe-coded apps audited exhaustively across all 12 pillars and a held-out set of 10 apps the engine had never seen, audited blind: a selected set, not a random sample and not a rate for AI-built apps in general. That pillar covers all input handling, not file paths alone, so it tells you where to look, not what you will find.

How it works: from a filename to a file outside the folder

Every traversal bug has the same three parts: a base folder, a name from the request, and a join that trusts the name.

Path traversal examples: the vulnerable call and the safe call

A path traversal example is one line: the code joins a base folder to a name from the request and opens the result. Node’s path.join normalizes the joined path, resolving parent-directory segments, so a name that climbs upward opens a file outside the folder. The safe version checks containment first.

In Node’s path module docs, path.join() “joins all given path segments together using the platform-specific separator as a delimiter, then normalizes the resulting path”, and path.normalize() works by “resolving ’..’ and ’.’ segments”. Python’s os.path.join has a second trap: “If a segment is an absolute path (which on Windows requires both a drive and a root), then all previous segments are ignored and joining continues from the absolute path segment.”

Here is the path traversal attack example in Node.js, with the fix under it. path.resolve returns an absolute, normalized path, and a later absolute segment replaces the base, which the check then refuses.

// Vulnerable: the request decides which folder is read
const unsafePath = path.join(UPLOAD_DIR, req.query.name);
await fs.readFile(unsafePath);

// Safe: resolve, then prove the result is still inside UPLOAD_DIR
const base = path.resolve(UPLOAD_DIR);
const target = path.resolve(base, String(req.query.name ?? ''));
if (!target.startsWith(base + path.sep)) return res.sendStatus(404);
await fs.readFile(target);

The same pair in Python, with Flask’s abort. The order matters: Python’s pathlib docs say is_relative_to “is string-based; it neither accesses the filesystem nor treats ”..” segments specially”, while resolve eliminates ”..” components, so resolve comes first. is_relative_to was added in Python 3.9.

# Vulnerable: the request decides which folder is read
open(os.path.join(BASE, name))

# Safe: resolve, then prove the result is still inside BASE
base = Path(BASE).resolve()
target = (base / name).resolve()
if not target.is_relative_to(base):
    abort(404)
open(target)

Stripping ../ out of the string is not a fix: encoded and nested forms of the same sequence exist, and string filters miss them; Wikipedia’s entry notes such a check is “sometimes mistakenly performed before percent-decoding”.

Directory traversal attack prevention: the five fixes, in order

Directory traversal attack prevention is 5 fixes in order: take a file id instead of a name, allowlist names where a name is needed, canonicalize then check the path is still inside the base folder, move uploads to object storage with generated keys, and run the app as a user that can read little.

The order is my recommendation for how to prevent path traversal. The first two remove the user’s string from the file call entirely; the third only guards it, so it is the one to get exactly right when you must prevent directory traversal with a free name in play.

  1. 01 Take an id, not a name. The request carries a file id, and the server looks up the stored path after checking the row belongs to the user, so no user string ever reaches a file API.
  2. 02 Where a name is unavoidable, match it against an allowlist: the set of templates or locales that actually exist. Never a blocklist.
  3. 03 Where a free name is unavoidable, canonicalize it to an absolute path, then check it is still inside the base folder, as in the examples above.
  4. 04 Keep uploads out of the app's filesystem: object storage, keys the server generates, and signed links to read them.
  5. 05 Shrink what a read can reach. On a server or container you run, the app runs as a user that cannot read anything outside its folder. Keep secrets out of files, and never commit a .env file.

For directory traversal, OWASP’s path traversal guidance makes the same point: “Prefer working without user input when using file system calls.” OWASP’s path traversal page also says to use “indexes rather than actual portions of file names when templating or using language files”, which is fix 2. OWASP’s input validation cheat sheet says the same for stored uploads: “Do not use any user controlled text for this filename or for the temporary filename.”

Fix 4 has its own ground: file upload testing and the controls behind it covers the upload side, and buckets and signed links are a separate subject: the object storage security checklist. For fix 5, a managed host picks the process user for you, so there the fix is the secrets part only, in my reading; the security risk of environment variables covers keeping them out of the repository.

A web application firewall rule is a backstop that buys time, not path traversal attack prevention, because the vulnerable line is still there; that is my reading, and the rule itself is a separate job: how to set up a web application firewall. The general habit behind the second and third fixes, validating at the server against a schema, is input validation for a web app.

The safe primitive per stack: Node.js, Python, Java, C# and PHP

The safe primitive is the same two steps in every stack: resolve the path to its absolute form, then check it still starts with the base folder, which in a string comparison means the folder plus a separator. Express res.sendFile with root and Flask send_from_directory are the helpers to reach for first.

StackThe resolve callThe containment checkThe framework helper that already does bothThe setting to confirm
Node.js and Expresspath.resolve(base, name)The result starts with base + path.sepres.sendFile(name, { root }): Express validates that the relative path resolves within rootroot is an absolute folder; dotfiles left at its default, “ignore”
Python, Flask and DjangoPath.resolve()is_relative_to(base) after resolving (Python 3.9 or later)Flask send_from_directory, which uses Werkzeug’s safe_join; Django: not stated in Django’s docsFlask’s directory argument never comes from the client
Javanormalize(), or toRealPath() for an existing filestartsWith(base), which compares name elements, not charactersNot stated in the Java SE Path docsRun the same check on every archive entry before extracting it
C# and .NETPath.GetFullPath(name, basePath)A prefix check that includes the trailing directory separatorNot stated in Microsoft’s GetFullPath docsA fully qualified name is returned as its own full path and the base is not used, so the check is what stops it
PHPrealpath(), which returns false if the file does not existA prefix check against the base folder plus /; basename() when only a single file name is allowedNot stated in PHP’s function docsallow_url_include is 0; allow_url_fopen defaults to 1, so turn it off if nothing needs it

To fix a path traversal vulnerability in Python, resolve with Path.resolve() and then test is_relative_to(base); the docs give its equivalent as u == p or u in p.parents, so it compares whole path parts. Flask send_from_directory does it for you: it “Uses safe_join() to ensure the path coming from the client is not maliciously crafted to point outside the specified directory.”

To fix a path traversal vulnerability in Java, resolve against the base, call normalize() or toRealPath(), then startsWith(base). Java’s Path API says a path starts with another when it “starts with the same name elements”, so “foo/bar” starts with “foo” but “does not start with “f” or “fo"". It also warns that normalize() does not touch the file system, and that removing ”..” and a preceding name “may result in the path that locates a different file” when that name is a symbolic link, which is when toRealPath() is the better call.

In Node, Express res.sendFile with root is the helper: “Express will validate that the relative path provided as path will resolve within the given root option.” In .NET, .NET Path.GetFullPath with a base path passes a fully qualified path straight through, so the prefix check is not optional. In PHP, realpath() “Returns canonicalized absolute pathname”, and basename() “operates naively on the input string”, so it is a filter for single names, not a containment check.

The rule across all five is my working rule: use the framework’s file-serving helper before writing your own, and when you compare strings, include the trailing separator, so /data/uploads-private does not pass as /data/uploads.

Full path disclosure: the error message that draws the map

Full path disclosure is a response that prints the server’s absolute file path, usually in a stack trace or a file-not-found message. It is filed under CWE-209, and it helps an attacker aim other file attacks.

MITRE’s CWE-209 entry, “Generation of Error Message Containing Sensitive Information”, gives the reason reports list it next to traversal findings: a leaked full pathname “could be used to select the proper number of ”..” sequences to navigate to the targeted file.” The fix is a production error response that returns a generic message and a request id, with the detail sent to your error tracker.

Debug mode stays off in production. Django’s DEBUG setting says “Never deploy a site into production with DEBUG turned on”, and adds that “File paths, configuration options and the like all give attackers extra information about your server.” Flask’s docs are blunter: “Do not run the development server, or enable the built-in debugger, in a production environment. The debugger allows executing arbitrary Python code from the browser.” The Next.js error overlay belongs to the development server and is never deployed, in my reading.

Where path traversal sits in the OWASP Top 10

Path traversal sits under A01:2025 Broken Access Control in the current OWASP Top 10, whose list of mapped weaknesses includes CWE-22, CWE-23 and CWE-36. A traversal finding goes in the access-control section of a review; file inclusion, CWE-98, sits under A05:2025 Injection.

Beyond the three traversal entries on the OWASP Top 10:2025 A01 Broken Access Control page, the other two names from the report table land elsewhere: CWE-73 under A06:2025 Insecure Design, and CWE-209, the error-message weakness, under A10:2025 Mishandling of Exceptional Conditions.

In a review organized by OWASP Top 10 category, my reading is that path traversal gets tested like any access-control finding: with two accounts, where one asks for the other’s file. The category-by-category test is how to test for the OWASP Top 10 vulnerabilities.

How to check your own app

Path traversal is checked with 6 tests on your own staging app: list every file API fed by request data, request a canary file one level up, repeat with an absolute path, import a test archive, read an error for server paths, and run one scanner pass.

Only ever run these against systems you own or are authorized in writing to test. A canary is a harmless text file with a unique line in it, placed one level above the folder a route reads from. Before check 2, confirm the running app can read it: on a serverless build it may not ship at all, since Next.js “will automatically trace each page and its dependencies to determine all of the files that are needed”, and a missing canary would let a vulnerable route pass. Next.js documents outputFileTracingIncludes for files the trace misses.

  1. 01 Search the codebase for file APIs fed by request data: fs.readFile, createReadStream, sendFile, open(, send_file, include, require( with a variable, and archive extract calls. Pass: every hit is listed with its route. Evidence: the list, with 'input reaches path: yes or no' on each row.
  2. 02 For each route, request a name containing one parent-directory segment that points at the canary. Pass: any 4xx refusal and no canary content in the body. Evidence: the request and the response.
  3. 03 Repeat check 2 with an absolute path to the same canary. Pass: the same refusal, no canary content. Evidence: the request and the response.
  4. 04 On any import route, upload a test archive you built whose one entry name points outside the extraction folder. Pass: no file written outside that folder, whether the import is rejected or the entry is rewritten inside it. Evidence: the directory listing before and after.
  5. 05 With the staging app in production mode and debug off, send a name that does not exist and read the error. Pass: no server path in the response body. Evidence: the response.
  6. 06 Run an active scan from an open-source scanner against staging only. Pass: no path traversal alert on the routes from check 1. Evidence: the scan report.

What a pass looks like differs by helper. Flask’s send_from_directory answers a missing file with “a 404 NotFound error”, so any 4xx counts, not one fixed code. For check 4 in Python, zipfile removes parent-directory components from member names, so a correct build rewrites the entry rather than rejecting it. tarfile refuses such a member with OutsideDestinationError under the data filter, which is the default only from Python 3.14; on 3.13 and lower, pass filter='data' yourself.

The Production Hardening Sprint has verify lines of its own for two of its deliverables. Deliverable 3.1, server-side request validation, is verified this way: submit invalid and hostile payloads and confirm safe rejection or handling. Deliverable 3.2, secure file uploads, is verified this way: test disallowed formats, oversized files, unauthorized downloads, and storage listings.

A wider authorized test of the whole app is web application penetration testing. If someone outside finds a traversal bug first, they need an address to report it to, and the usual way to publish one starts from a security.txt file example.

Where the sprint does this

In the Production Hardening Sprint, five deliverables sit in this part of the published list. 3.1: we validate every data-writing endpoint with explicit schemas and safe input/output handling, including injection defenses. 3.2: we validate upload types and sizes, enforce bucket permissions, and protect access to stored files. 4.14: we use signed, expiring links for private files, enforce size and type limits, scan user uploads for malware, and set a retention rule for uploads. 3.7: we review the application against the OWASP Top 10 and record findings, fixes, and evidence by category. 3.10: we test the five highest-risk externally reachable attack surfaces, remediate findings, and deliver the methods and evidence, a targeted test AxonBuild performs. Formal third-party certifications and independent audit opinions are separate from the sprint deliverables. Each deliverable and its verify line is on the published list of 123 deliverables.

Common questions about file inclusion and path traversal

Which tool can test for directory traversal vulnerabilities?

An open-source scanner’s active scan is the usual first tool. ZAP, which calls itself “Free and open source”, has an active scan rule named Path Traversal (alert 6, mapped to CWE-22) and a separate Remote File Inclusion rule (alert 7, CWE-98), both listed in ZAP’s alert list. A scanner finds the routes it can reach; the code search in check 1 of the list above finds the rest. Run it against staging only.

What is an absolute path traversal vulnerability?

An absolute path traversal vulnerability, CWE-36, is one where the input is a full path that replaces the base folder instead of climbing out of it. MITRE’s wording is that the product “does not properly neutralize absolute path sequences such as “/abs/path” that can resolve to a location that is outside of that directory.” In Python an absolute part makes os.path.join ignore the base, and in Node path.resolve does the same. The containment check catches it; a filter that strips dots does not.

How do I know if I have RCE?

You know from evidence, not a feeling: a finding with a reproduced command, processes or outbound connections in the logs that you cannot explain, or files changed outside a deploy. That list is my reading, not a standard. If you see any of it, treat it as an incident; my article on what to do when a vibe-coded app got hacked is the place to start.

What is the difference between ACE and RCE?

Arbitrary code execution (ACE) means an attacker can run code of their choosing on the system at all. Remote code execution (RCE) means they can do it over the network, without prior access to the machine. Every RCE is an ACE; an ACE that needs a local account first is not an RCE. These are my definitions, not a vendor’s.

Is PHP a security risk?

PHP is not a security risk by itself. Current PHP ships with allow_url_include set to 0 and deprecated since 7.4.0, so remote inclusion needs someone to turn it back on. The risk is old versions, old settings, and code that passes request input to include.