---
key: "file-inclusion"
title: "Prevent file inclusion: LFI, RFI, dynamic imports and template names"
url: "https://softwaresecurity.com/domains/appsec/secure-coding/file-inclusion/"
api: "https://softwaresecurity.com/api/v1/articles/file-inclusion.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-98","CWE-73","CWE-829","CWE-22","CWE-94","A05:2025","A01:2025","V5.3.1","V5.3.2","V1.3.2","V1.3.7","CAPEC-252","CAPEC-193","WSTG-INPV-11","WSTG-ATHZ-01"]
tags: ["Injection","File uploads and archives"]
stages: ["build"]
roles: ["developer","appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Prevent file inclusion: LFI, RFI, dynamic imports and template names

Stop request data from choosing which file, module or template your application loads: local and remote file inclusion in PHP, dynamic require() and import() in Node.js, importlib and template names in Python, Thymeleaf view names in Spring, render in Rails, and the allowlist fix that works in all of them.

Canonical URL: https://softwaresecurity.com/domains/appsec/secure-coding/file-inclusion/

## 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.

## Recommended defaults

| 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 |

## 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.

## 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.

Audience: Developers who load files, modules or templates by name, and testers who triage file inclusion findings.

Before you start:

- Read server-side code in at least one of PHP, JavaScript, Python, Java or Ruby.
- Know how relative paths, `../` segments and URL encoding work.

Time: About 29 minutes: 10 to read and 19 for the exercise

## 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.

| 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 |

## 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.

```php
<?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.

```php
<?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.

```js
// 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.

```js
// 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.

```python
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.

```python
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.

```java
@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.

```go
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



| 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 material

```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. Rate the PHP theme loader

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

Worked answer: 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. Break the basename check

Show why S2's allowlist check does not protect the include.

Worked answer: 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. Fix the Node.js export

Explain what S3 accepts and write the replacement.

Worked answer: 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. 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.

Worked answer: 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.

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.

Scope: 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.

## Executable practice

- [File upload lab: Accept avatars without publishing pages](https://softwaresecurity.com/labs/file-upload/)

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [CWE-98: Improper Control of Filename for Include/Require Statement in PHP Program](https://cwe.mitre.org/data/definitions/98.html). CWE 4.20.
- [OWASP WSTG 4.2: Testing for Local File Inclusion (part of WSTG-INPV-11)](https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/07-Input_Validation_Testing/11.1-Testing_for_Local_File_Inclusion)
- [OWASP WSTG 4.2: Testing for Remote File Inclusion (part of WSTG-INPV-11)](https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/07-Input_Validation_Testing/11.2-Testing_for_Remote_File_Inclusion)
- [PHP manual: filesystem and streams configuration options (allow_url_fopen, allow_url_include)](https://www.php.net/manual/en/filesystem.configuration.php)
- [PHP manual: backward incompatible changes in PHP 8.0 (Phar metadata)](https://www.php.net/manual/en/migration80.incompatible.php)
- [phpMyAdmin security announcement PMASA-2018-4 (CVE-2018-12613)](https://www.phpmyadmin.net/security/PMASA-2018-4/). 19 June 2018.
- [NVD: CVE-2016-0752, Ruby on Rails Action View directory traversal via render](https://nvd.nist.gov/vuln/detail/cve-2016-0752)
- [GitHub Security Lab GHSL-2021-020: insecure template handling in hbs (CVE-2021-32822)](https://securitylab.github.com/advisories/GHSL-2021-020-pillarjs-hbs/). 27 May 2021.
- [Veracode Research: Spring view manipulation vulnerability](https://github.com/veracode-research/spring-view-manipulation)
- [Node.js documentation: ECMAScript modules, data: imports](https://nodejs.org/api/esm.html#data-imports). Node.js v26.11.1.
- [OWASP ASVS 5.0.0, V5 File Handling (5.3.1, 5.3.2)](https://github.com/OWASP/ASVS/blob/v5.0.0_release/5.0/en/0x14-V5-File-Handling.md)
- [PortSwigger Web Security Academy: path traversal](https://portswigger.net/web-security/file-path-traversal)