In short

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() accepts data: 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 or http:// have been bypassed by encodings, wrappers and truncation for two decades; an allowlist map has nothing to bypass.

Verify it

  1. Search the code for include, require, include_once and require_once with a variable; require( and import( with a variable or template literal; importlib.import_module and __import__; render_template, render and res.render with request data; controller methods that return a concatenated view name; and template.ParseFiles with a non-literal path.
  2. Send ../../../../etc/passwd, ....//....//etc/passwd, %2e%2e%2f, double-encoded %252e%252e%252f, an absolute path and a value ending in %00 to 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.
  3. On PHP endpoints, send a php://filter resource request, a data:// URL, a phar:// path and an http:// URL on a server you control; confirm no file contents are returned, no execution and no inbound request.
  4. On Node.js endpoints that reach import() or require(), send data:text/javascript,export default 1 and file:///etc/hosts and confirm the request fails before any module resolves.
  5. Run php -i and check allow_url_include, allow_url_fopen and open_basedir inside each deployed PHP image, not on a developer laptop.
  6. 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

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 29 minutes: 10 to read and 19 for the exercise

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 practice

From a page parameter to an executed include

AttackerPHP routerServer filesGET ?page=../uploads/avatar1pages/../uploads/avatar.php2include: uploaded PHP runs3Fixed: unknown key, 4044Trust boundaryUntrusted input1Step, described below
  1. RequestThe attacker sends ?page=../uploads/avatar to a router that includes pages/, the value and .php.
  2. Path builtThe path becomes pages/../uploads/avatar.php, and ../ leaves pages/.
  3. Include runsinclude executes the PHP in that file: here an upload kept under the user's file name.
  4. Fixed routerA key map selects only fixed paths; any other value gets a 404 before require.
Filtering the value leaves encodings, wrappers and truncation to defend against. With a key-to-file map, no request value reaches include.

See how a filename parameter chooses code to run

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.

See how a filename parameter chooses code to run
PlatformSinkEffect of controlled input
PHPinclude, require, include_once, require_onceLoaded PHP runs; other files are printed
Node.jsrequire(name), import(name)Loads any .js, .json or .node file on disk; import() also runs data: URLs
Pythonimportlib.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 nameThe name is parsed as a Thymeleaf expression
Java (JSP)jsp:include page=..., RequestDispatcher.includeReaches any resource in the web app, including WEB-INF
Ruby on Railsrender with a dynamic template or fileReaches templates or files outside the intended set (CVE-2016-0752)
Gotemplate.ParseFiles(path)Reads and executes any file the process can open as a template

Spot the vulnerable include in PHP

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.

Vulnerableindex.phpPHP
<?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 file

Rate a local file inclusion as code execution

A 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_basedir and an includable upload directory each widen it.

Map a key to a fixed file in PHP

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.

Fixedindex.phpPHP
<?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];

Check the exact value you include

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.

Harden PHP so a missed include does less damage

  • Keep allow_url_include off (the default). It is INI_SYSTEM, so code cannot turn it on at run time, and it was deprecated in PHP 7.4.
  • Turn allow_url_fopen off when no code path needs file functions to read URLs; fetch remote data with an HTTP client instead.
  • Set open_basedir to the application's own directories so an LFI cannot read /etc or 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.

Don't build require() or import() specifiers from input

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.

Vulnerableloaders.mjsJavaScript
// 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
}

Use a map of loaders in Node.js

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.

Fixedloaders.mjsJavaScript
// 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;
}

Keep importlib away from request data in Python

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.

Fixedexporters.pyPython
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()

Choose template names in code, not in the request

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.

Fixedapp.pyPython
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)

Never return request data as a Spring view name

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.

FixedWelcomeController.javaJava
@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;
    }
}

Keep render options and template names static in Rails and Express

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.

Embed templates and look them up by name in Go

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.

Fixedpages.goGo
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)
	}
}

Contain a path when you truly must accept one

Some features, such as serving documentation pages from a directory tree, really do take a path. Then decode once, resolve the path against a fixed base, and check that the result is still inside the base before loading it; resolve symlinks if the directory can contain them. In Node.js compare path.relative(base, resolved) for a leading .. or an absolute result; in Python use Path.resolve() and is_relative_to(); in Go 1.24 and later open files through os.Root, which refuses to escape its directory, or check filepath.IsLocal first. The path traversal fix cards at /fix/path-traversal/ have tested versions of this resolve-and-contain check for seven stacks, and the lab at /labs/path-traversal-python/ has you write one. Containment stops traversal, but it does not stop an attacker choosing any file inside the base, so keep executable code and secrets out of that directory.

Find inclusion points with scanners and payloads

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.

Learn from real file inclusion failures

Learn from real file inclusion failures
CaseWhat failedControl 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 valueA 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 filesStatic 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 filesBuild 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 namesKeep 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
Plain text
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 name
  1. 1. Rate the PHP theme loader

    Explain what S1 allows given N1 and N2, and whether the fixed /header.php suffix helps.

    Expected result and reasoning

    Exploitable LFI; rate it as code execution. ../ in the cookie reaches any file named header.php, which N2's uploads, kept under original names, can supply; with open_basedir unset, only file permissions limit it. Fix: map theme keys to fixed directories, store uploads outside the web root under generated names, and set open_basedir.

  2. 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 include ALLOWED[$key] from a key map.

  3. 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 .mjs file 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 with Object.hasOwn; reject unknown keys with 400.

  4. 4. Judge the Flask, Spring and Go cases

    Decide what S4, S5 and S6 expose and whether Jinja's .. check or the docs/ prefix makes them safe.

    Expected result and reasoning

    S4: Jinja rejects .., so it stays in the template folder, but any template under help/ renders with current_user in context, so map topics to names anyway. S5: Thymeleaf parses the returned view name as an expression, so a crafted type can run code; map it to fixed view names (Map.of("monthly", "reports/monthly", ...)) and return 404 otherwise. S6 is serious: ParseFiles reads docs/../../etc/passwd or any file and executes it as a template; parse embedded templates once with ParseFS and select by allowlist.

Your deliverable: A table of S1-S6 with exploitability, severity (disclosure or code execution) and the replacement code or configuration, plus the 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

File upload lab: Accept avatars without publishing pages

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.

  1. CWE-98: Improper Control of Filename for Include/Require Statement in PHP Program. CWE 4.20.
  2. OWASP WSTG 4.2: Testing for Local File Inclusion (part of WSTG-INPV-11)
  3. OWASP WSTG 4.2: Testing for Remote File Inclusion (part of WSTG-INPV-11)
  4. PHP manual: filesystem and streams configuration options (allow_url_fopen, allow_url_include)
  5. PHP manual: backward incompatible changes in PHP 8.0 (Phar metadata)
  6. phpMyAdmin security announcement PMASA-2018-4 (CVE-2018-12613). 19 June 2018.
  7. NVD: CVE-2016-0752, Ruby on Rails Action View directory traversal via render
  8. GitHub Security Lab GHSL-2021-020: insecure template handling in hbs (CVE-2021-32822). 27 May 2021.
  9. Veracode Research: Spring view manipulation vulnerability
  10. Node.js documentation: ECMAScript modules, data: imports. Node.js v26.11.1.
  11. OWASP ASVS 5.0.0, V5 File Handling (5.3.1, 5.3.2)
  12. 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
OWASP WSTG v4.2 tests
WSTG-INPV-11 · WSTG-ATHZ-01

Download this guide as Markdown