File inclusion happens when request data chooses which file, module or template the application loads: PHP include and require, Node.js require() and import(), Python importlib, a template name passed to Jinja, Thymeleaf or Rails render. Local file inclusion (LFI) loads a file already on the server; remote file inclusion (RFI) loads code from a URL. Fix it by mapping a user-supplied key to a fixed allowlist of files and never building the path from input. If a path is unavoidable, resolve it and check it stays inside one directory. Then harden the platform: keep PHP allow_url_include off, store uploads where nothing can include them, and treat open_basedir as a backstop.
- A filename parameter that reaches an include, a module loader or a template lookup is a code-loading decision, not a string to filter.
- Rate a PHP LFI as code execution, not information disclosure: an included file is executed, and attackers commonly steer the include toward a file whose contents they influence.
- Node.js
import()acceptsdata:URLs, so a specifier built from input can run attacker JavaScript with no file on disk; this ran on Node.js 26. - Template names are file lookups too. Jinja blocks
../but still renders any template in the folder with the context you pass, and Thymeleaf evaluates a Spring view name as an expression. - Validate the exact value you load. phpMyAdmin's CVE-2018-12613 allowlist check passed a value that
include()then resolved to other files. - Filters for
../, file extensions orhttp://have been bypassed by encodings, wrappers and truncation for two decades; an allowlist map has nothing to bypass.
| Setting | Value | Note | Source |
|---|---|---|---|
| Choosing a file, module or template from input | A fixed map from key to path or loader, built in code | Input only selects a key; an unknown key is a 404. Use Object.hasOwn or a Map in JavaScript so __proto__ is not a key. | OWASP WSTG 4.2, Testing for Local File Inclusion |
PHP allow_url_include | Off ("0", the default) | Deprecated since PHP 7.4. With it on, include accepts http://, ftp:// and data:// URLs, which is remote file inclusion. | PHP manual: filesystem and streams configuration |
PHP allow_url_fopen | Off unless file functions must read URLs (default "1") | allow_url_include requires it. Fetch remote data with an HTTP client that has its own SSRF controls instead. | PHP manual: filesystem and streams configuration |
PHP open_basedir | The application's code and data directories | Defense in depth: it limits which files an LFI can read, but some wrapper techniques need no file at all, so it is a backstop. | phpMyAdmin PMASA-2018-4 |
| Uploaded and generated files | Outside the web root and every include path, with server-generated names | An uploaded file that contains code is the classic way a local inclusion turns into remote code execution. | OWASP ASVS 5.0.0 V5.3.1 and V5.3.2 |
Node.js require() and import() | Literal specifiers, or a map of loader functions | import() resolves data:, file: and node: URLs, absolute paths and ../ segments. | Node.js 26 ECMAScript modules docs; tested on 26.3.0 |
| Template names (Jinja, Thymeleaf, ERB, Handlebars) | Chosen in code; never a request parameter | In Spring MVC, return data with @ResponseBody, not a view name built from input. | OWASP ASVS 5.0.0 V1.3.7; Veracode Spring view manipulation research |
- Search the code for
include,require,include_onceandrequire_oncewith a variable;require(andimport(with a variable or template literal;importlib.import_moduleand__import__;render_template,renderandres.renderwith request data; controller methods that return a concatenated view name; andtemplate.ParseFileswith a non-literal path. - Send
../../../../etc/passwd,....//....//etc/passwd,%2e%2e%2f, double-encoded%252e%252e%252f, an absolute path and a value ending in%00to every parameter that names a page, language, theme, template or plugin; expect a 400 or 404, never file content or a 500 with a path in it. - On PHP endpoints, send a
php://filterresource request, adata://URL, aphar://path and anhttp://URL on a server you control; confirm no file contents are returned, no execution and no inbound request. - On Node.js endpoints that reach
import()orrequire(), senddata:text/javascript,export default 1andfile:///etc/hostsand confirm the request fails before any module resolves. - Run
php -iand checkallow_url_include,allow_url_fopenandopen_basedirinside each deployed PHP image, not on a developer laptop. - Keep CodeQL's path-injection queries (
js/path-injection,py/path-injection,java/path-injection) or equivalent Semgrep and Brakeman rules on every pull request, and add a regression test that requests a known traversal payload.
- Applies to
- PHP 8.x; allow_url_include deprecated since 7.4; Phar metadata no longer auto-unserialized since 8.0
- Node.js 26.3.0 tested; data: imports in every supported release
- Flask / Jinja2 3.1.3 / 3.1.6 tested
- Spring MVC with Thymeleaf Any version that resolves view names from controller return values
- Ruby on Rails Before the January 2016 fixes for CVE-2016-0752
- Go html/template ParseFS since 1.16; os.Root since 1.24
Server-side code that loads files, modules or templates by name: PHP, Node.js, Python, Java with Spring MVC, Ruby on Rails and Go. PHP wrapper behavior depends on `php.ini`, so check the settings your images actually ship.
- Part of
- Workflows: Fix a finding, Secure a feature
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Recognize file inclusion sinks in module loaders, include statements and template engines.
- Explain why a local file inclusion is usually rated as code execution, not just disclosure.
- Replace path building with a fixed allowlist map, and contain paths that cannot be avoided.
- Test inclusion points with traversal, encoding and wrapper payloads.
Before you start
Developers who load files, modules or templates by name, and testers who triage file inclusion findings.
- Read server-side code in at least one of PHP, JavaScript, Python, Java or Ruby.
- Know how relative paths,
../segments and URL encoding work.
Refresh first: Extract archives without escaping the destination
Go to the guided practiceFrom a page parameter to an executed include
- RequestThe attacker sends
?page=../uploads/avatarto a router that includespages/, the value and.php. - Path builtThe path becomes
pages/../uploads/avatar.php, and../leavespages/. - Include runs
includeexecutes the PHP in that file: here an upload kept under the user's file name. - Fixed routerA key map selects only fixed paths; any other value gets a 404 before
require.
include.An application has a file inclusion flaw when input decides which file, module or template it loads and interprets. CWE-98 names the PHP case: a request parameter reaches include or require. Local file inclusion (LFI) points the include at a file already on the server, often with ../ path traversal; remote file inclusion (RFI) points it at a URL the attacker controls. Because the loaded file is executed or rendered rather than just read, the impact is usually worse than plain path traversal.
PHP made the bug famous, but every platform has a loader that does the same thing when given a name built from input. The table lists the common sinks and the effect of controlled input.
| Platform | Sink | Effect of controlled input |
|---|---|---|
| PHP | include, require, include_once, require_once | Loaded PHP runs; other files are printed |
| Node.js | require(name), import(name) | Loads any .js, .json or .node file on disk; import() also runs data: URLs |
| Python | importlib.import_module, __import__ | Imports and runs any reachable module's top-level code |
| Python (Flask, Jinja2) | render_template(name), {% include name %} | Renders any template in the loader's folders with your context |
| Java (Spring MVC, Thymeleaf) | A controller's returned view name | The name is parsed as a Thymeleaf expression |
| Java (JSP) | jsp:include page=..., RequestDispatcher.include | Reaches any resource in the web app, including WEB-INF |
| Ruby on Rails | render with a dynamic template or file | Reaches templates or files outside the intended set (CVE-2016-0752) |
| Go | template.ParseFiles(path) | Reads and executes any file the process can open as a template |
This router appends .php and assumes that limits the damage. It does not. The parameter accepts ../ segments, and PHP's stream wrappers change how a target is read. One old trick is gone: since PHP 5.3.4 a path containing a NUL byte is invalid, so a %00 suffix no longer strips the extension.
<?php
// Vulnerable: the request decides which file PHP includes.
$page = $_GET['page'] ?? 'home';
include __DIR__ . '/pages/' . $page . '.php';
// ?page=../../vendor/some/file -> includes a different PHP fileA first proof of concept often just prints a system file, which tempts teams to rate an LFI as information disclosure. That underestimates it. Because an included file is executed, the real question is whether an attacker can influence the contents of any file the include can reach. On real systems that is common, so default to treating a PHP LFI as remote code execution when you triage it.
- Uploaded files stored where the application can include them are the most common route, which is why upload storage location matters so much.
- Server-side stream wrappers (for example
php://filter) change how a target is read, so defenses that only inspect the apparent file name are incomplete. - Files that record request data, such as logs and session files, let an attacker influence content indirectly.
- The reachable set grows with configuration: an unset
open_basedirand an includable upload directory each widen it.
Keep the set of includable files in code. The request supplies a key; the map supplies the path. Nothing from the request reaches include, so there is no encoding, wrapper or traversal to defend against.
<?php
// Fixed: input selects a key; only paths written here can be included.
const PAGES = [
'home' => __DIR__ . '/pages/home.php',
'pricing' => __DIR__ . '/pages/pricing.php',
'contact' => __DIR__ . '/pages/contact.php',
];
$key = $_GET['page'] ?? 'home';
if (!is_string($key) || !array_key_exists($key, PAGES)) {
http_response_code(404);
exit;
}
require PAGES[$key];An allowlist only works if it checks the same string the include uses. phpMyAdmin 4.8.0 and 4.8.1 checked a decoded, truncated copy of the target parameter against a list of allowed pages, but passed a different value to include; a crafted value passed the check while the include resolved to other files (CVE-2018-12613, PMASA-2018-4, fixed in 4.8.2). The advisory notes that a restrictive open_basedir greatly limited what attackers could read, which is exactly the backstop role it should play.
The lesson generalizes. Decode input once, validate the decoded value, and use that same value (ASVS 5.0.0 1.1.1). Better still, don't validate a path at all: look the key up in a map and use the map's value.
- Keep
allow_url_includeoff (the default). It isINI_SYSTEM, so code cannot turn it on at run time, and it was deprecated in PHP 7.4. - Turn
allow_url_fopenoff when no code path needs file functions to read URLs; fetch remote data with an HTTP client instead. - Set
open_basedirto the application's own directories so an LFI cannot read/etcor other applications' files. - Store uploads outside the web root and outside every include path, under generated names, and configure the web server never to execute PHP in upload directories (ASVS 5.0.0 V5.3.1).
- Log and alert on parameters containing
php://,data://,phar://,expect://or long runs of../, which are rarely legitimate.
Node.js resolves a specifier the same way whether you wrote it or the attacker did. A locale or plugin name joined into a path accepts ../ segments and absolute paths, so it can load any .js, .json or native .node file the process can read, including an uploaded one. Dynamic import() is broader: Node.js supports data: URLs with the text/javascript MIME type, so in a test on Node.js 26.3.0, import("data:text/javascript,export default process.version") ran supplied code with no file on disk.
// Vulnerable: the specifier is built from, or is, request data.
export async function loadLocale(lang) {
return (await import(`./locales/${lang}.mjs`)).default; // ../ escapes the folder
}
export async function loadPlugin(name) {
return (await import(name)).default; // a data: URL runs supplied code
}A map from key to a function with a literal specifier keeps every loadable module visible to reviewers and bundlers. Check membership with Object.hasOwn so inherited keys such as __proto__, constructor and toString are rejected; in a test, all three were.
// Fixed: input selects a key; each specifier is a literal in the source.
const LOCALES = {
en: () => import("./locales/en.mjs"),
fr: () => import("./locales/fr.mjs"),
de: () => import("./locales/de.mjs"),
};
export async function loadLocale(lang) {
if (typeof lang !== "string" || !Object.hasOwn(LOCALES, lang)) {
throw new RangeError("unsupported locale");
}
return (await LOCALES[lang]()).default;
}importlib.import_module(name) imports whatever module the name resolves to and runs its top-level code. With a name from a request, an attacker can import modules you never meant to expose, or run code they managed to drop onto an import path. Register plugins explicitly in a dict, or load them through installed package entry points (importlib.metadata.entry_points), which only exposes code an administrator installed.
from importlib.metadata import entry_points
# Vulnerable: importlib.import_module(request.args["exporter"])
# Fixed: only exporters installed as packages, selected by a registered name.
EXPORTERS = {ep.name: ep for ep in entry_points(group="reports.exporters")}
def get_exporter(name: str):
entry = EXPORTERS.get(name)
if entry is None:
raise LookupError("unknown exporter")
return entry.load()Jinja2's FileSystemLoader refuses names containing a .. segment, so render_template("../app.py") raises TemplateNotFound. That does not make a user-chosen name safe. In a test with Flask 3.1.3 and Jinja2 3.1.6, a help endpoint that rendered request.args["page"] with a secret in its context served admin/debug.html, which printed the secret. Any template in the folder becomes reachable, with whatever variables the vulnerable view passes. The same applies to {% include name %} when name comes from data. ASVS 5.0.0 1.3.7 asks that templates are never built from untrusted input.
from flask import Flask, abort, render_template, request
app = Flask(__name__)
# Vulnerable: render_template(request.args["page"], ...) renders any template.
HELP_PAGES = {
"billing": "help/billing.html",
"security": "help/security.html",
}
@app.get("/help")
def help_page():
template = HELP_PAGES.get(request.args.get("page", ""))
if template is None:
abort(404)
return render_template(template)In Spring MVC, a @Controller method's String return value is a view name. With Thymeleaf, the view is resolved by parsing the name as a fragment expression, so a name built from input becomes expression injection. Veracode's research documented that a controller returning a view name concatenated from a request parameter can lead to code execution, and that methods returning void can be affected too because Spring then derives the view name from the request path.
Return data with @ResponseBody or @RestController, choose views from a fixed set, and keep request data out of redirect: targets as well, which otherwise become open redirects. Thymeleaf's Spring integration now rejects view names that appear in the request, but published bypasses such as CVE-2021-43466 against thymeleaf-spring5 3.0.12 show it is a backstop, not the fix.
@Controller
class WelcomeController {
private static final Map<String, String> VIEWS = Map.of(
"en", "user/en/welcome",
"fr", "user/fr/welcome");
// Vulnerable: return "user/" + lang + "/welcome";
@GetMapping("/welcome")
String welcome(@RequestParam(defaultValue = "en") String lang) {
String view = VIEWS.get(lang);
if (view == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
return view;
}
}Rails before 3.2.22.1, 4.1.14.1, 4.2.5.1 and 5.0.0.beta1.1 let an application that passed request data to render read arbitrary files with ../ (CVE-2016-0752). The fix restricted the lookup, but rendering a template chosen by params is still a design flaw, and Brakeman reports it as a Dynamic Render Path warning.
Express view engines have a related trap: the second argument of res.render mixes template data with engine options. GitHub Security Lab's GHSL-2021-020 (CVE-2021-32822) showed that passing req.query straight into res.render with the hbs engine let an attacker override internal options and disclose files. Pass an object you build field by field, never the request object or its query.
template.ParseFiles(filepath.Join("templates", name)) with a name from the request reads and executes any file the process can open. Parse a fixed set of templates once at startup, from an embedded file system, and select one with an allowlist of names. Nothing from the request touches the file system.
package web
import (
"embed"
"html/template"
"net/http"
)
//go:embed templates/*.html
var files embed.FS
var pages = template.Must(template.ParseFS(files, "templates/*.html"))
var allowed = map[string]string{"about": "about.html", "faq": "faq.html"}
func page(w http.ResponseWriter, r *http.Request) {
name, ok := allowed[r.URL.Query().Get("p")]
if !ok {
http.NotFound(w, r)
return
}
if err := pages.ExecuteTemplate(w, name, nil); err != nil {
http.Error(w, "render failed", http.StatusInternalServerError)
}
}Static analysis finds most of these bugs because the sink is a known function. CodeQL's path-injection queries (js/path-injection, py/path-injection, java/path-injection and their equivalents) report uncontrolled data used in a path expression, Semgrep has rules for dynamic require, include and render calls, and Brakeman covers Rails. Dynamic testing follows OWASP WSTG 4.2: WSTG-INPV-11 covers local and remote file inclusion as a form of code injection, and WSTG-ATHZ-01 covers directory traversal and file include. Fuzz every parameter that names a page, language, theme, template, report or plugin, not just ones called file.
| Case | What failed | Control that would have stopped it |
|---|---|---|
| CVE-2018-12613, phpMyAdmin 4.8.0-4.8.1 (June 2018) | An allowlist check on a decoded, truncated copy of the target parameter; include() used a different value | A key-to-file map, or validating and including the identical, once-decoded value |
| CVE-2016-0752, Ruby on Rails Action View (January 2016) | Applications passed request data to render, which accepted ../ and read arbitrary files | Static template names; the framework fix restricted lookups |
| CVE-2021-32822, hbs view engine for Express (May 2021) | Request data passed as render options overrode engine configuration and disclosed files | Build the render context field by field; never pass req.query or req.body |
| CVE-2021-43466, thymeleaf-spring5 3.0.12 (November 2021) | A bypass of Thymeleaf's checks against expressions in template names | Keep request data out of view names instead of relying on the engine's checks |
Guided practice
Review these snippets and notes from a polyglot customer portal before release. Decide which are exploitable, how severe each is, and write the fix.
Supplied scenario material
S1 PHP theme loader: include "themes/" . $_COOKIE['theme'] . "/header.php";
S2 PHP report page: if (in_array(basename($_GET['r']), $ALLOWED)) include $_GET['r'];
S3 Node.js export API: const fmt = await import(`./formats/${req.query.format}.mjs`);
S4 Flask help page: return render_template(f"help/{request.args['topic']}.html", user=current_user)
S5 Spring controller: return "reports/" + request.getParameter("type"); // Thymeleaf views
S6 Go docs server: t, _ := template.ParseFiles("docs/" + r.URL.Query().Get("page"))
Notes
N1 php.ini in the production image: allow_url_fopen=1, allow_url_include=0, open_basedir unset
N2 Uploaded avatars are stored in /var/www/html/uploads/ under the user's original file name
N3 Templates and docs are the only files meant to be loadable by name1. Rate the PHP theme loader
Explain what S1 allows given N1 and N2, and whether the fixed
/header.phpsuffix helps.Expected result and reasoning
Exploitable LFI; rate it as code execution.
../in the cookie reaches any file namedheader.php, which N2's uploads, kept under original names, can supply; withopen_basedirunset, only file permissions limit it. Fix: map theme keys to fixed directories, store uploads outside the web root under generated names, and setopen_basedir.2. Break the basename check
Show why S2's allowlist check does not protect the include.
Expected result and reasoning
It checks
basename($_GET['r'])but includes the full$_GET['r']. A value ending in an allowed name passes the check while the include resolves to another file or stream. Validate and include the same value, or includeALLOWED[$key]from a key map.3. Fix the Node.js export
Explain what S3 accepts and write the replacement.
Expected result and reasoning
The fixed prefix and suffix rule out a
data:URL, but../loads any.mjsfile the process can read, including one an attacker wrote. Replace it with a map of loaders with literal specifiers, such as{csv: () => import("./formats/csv.mjs")}, checked withObject.hasOwn; reject unknown keys with 400.4. Judge the Flask, Spring and Go cases
Decide what S4, S5 and S6 expose and whether Jinja's
..check or thedocs/prefix makes them safe.Expected result and reasoning
S4: Jinja rejects
.., so it stays in the template folder, but any template underhelp/renders withcurrent_userin context, so map topics to names anyway. S5: Thymeleaf parses the returned view name as an expression, so a craftedtypecan run code; map it to fixed view names (Map.of("monthly", "reports/monthly", ...)) and return 404 otherwise. S6 is serious:ParseFilesreadsdocs/../../etc/passwdor any file and executes it as a template; parse embedded templates once withParseFSand select by allowlist.
php.ini and upload-storage changes implied by N1-N3.A paper review: it does not run PHP, test which wrappers a build enables, or prove there are no other include points; a real review adds code search and dynamic tests.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Repair the code
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- CWE-98: Improper Control of Filename for Include/Require Statement in PHP Program. CWE 4.20.
- OWASP WSTG 4.2: Testing for Local File Inclusion (part of WSTG-INPV-11)
- OWASP WSTG 4.2: Testing for Remote File Inclusion (part of WSTG-INPV-11)
- PHP manual: filesystem and streams configuration options (allow_url_fopen, allow_url_include)
- PHP manual: backward incompatible changes in PHP 8.0 (Phar metadata)
- phpMyAdmin security announcement PMASA-2018-4 (CVE-2018-12613). 19 June 2018.
- NVD: CVE-2016-0752, Ruby on Rails Action View directory traversal via render
- GitHub Security Lab GHSL-2021-020: insecure template handling in hbs (CVE-2021-32822). 27 May 2021.
- Veracode Research: Spring view manipulation vulnerability
- Node.js documentation: ECMAScript modules, data: imports. Node.js v26.11.1.
- OWASP ASVS 5.0.0, V5 File Handling (5.3.1, 5.3.2)
- PortSwigger Web Security Academy: path traversal
Reference information
- SDLC phase
- Development
- ASVS 5.0.0 requirements
- v5.0.0-5.3.1 · v5.0.0-5.3.2 · v5.0.0-1.3.2 · v5.0.0-1.3.7
- CAPEC attack patterns
- OWASP WSTG v4.2 tests
- WSTG-INPV-11 · WSTG-ATHZ-01