# EyalSec > EyalSec makes es-python, a secure Python runtime that runs your existing code unchanged, follows untrusted data through the running program, and reports or blocks it when it reaches a dangerous operation such as a SQL query, a shell command, deserialization or a file path. Because it checks the operation the program actually performs, with the data that really reached it, each finding is a flow that happened on real input, shown on the EyalSec dashboard with where the data came from and the stack trace. It covers third-party libraries such as Django the same way as your own code, and it can report a flow (Report mode) or also stop it at that operation (Report and Raise mode). EyalSec is for teams that run Python web backends and services (Django, Flask, FastAPI, background workers) and want to see, or stop, attacker-controlled data reaching a dangerous operation in production. It is a secure Python. EyalSec is an early-stage company based in Israel. ## Products - **es-python** (the lead product): the EyalSec Python runtime. You install it on Linux next to your normal Python and start your existing program with `es-python` instead of `python`; your code, frameworks and packages run unchanged. It follows untrusted data through the running program and reports, or blocks, the flow when it reaches a dangerous operation. Python 3.9, 3.10, 3.11, 3.12, 3.13 and 3.14, on x86_64 and aarch64, on Ubuntu, Debian, the RHEL 9 family, Amazon Linux 2023, Fedora and Arch Linux. - **es-chromium**: the EyalSec browser, a desktop browser for security testing (Linux x86_64). While you browse the site you are testing, it follows page input (the URL, messages from other windows, storage, fetched responses) through the page's JavaScript and reports DOM-based cross-site scripting (DOM XSS) flows that really happened in the page. It reports; it does not block. - **The dashboard** at eyalsec.com: where you add machines, read events, filter them and set rules. A JSON API does what the dashboard does. ## What it detects es-python reports vulnerabilities where attacker-controlled data reaches a dangerous operation, at the moment the running program performs it: - SQL injection (a database query built from untrusted data) - OS command injection (a shell command or subprocess) - Code injection (eval, exec, compile) - Insecure deserialization (pickle and similar) - Path traversal (a file path opened, listed or created) - Server-side request forgery, SSRF (a network connection to a chosen host) - Unsafe dynamic imports, and Python code loaded from files another user can change Untrusted data comes from the sources you switch on: network sockets, files, standard input, environment variables and command-line arguments, data read back from a database, and files another user can write, including code loaded from them. A new machine watches network input and file reads; you switch the rest on. Flaws with no data flow, such as a missing authorization check, CSRF or a timing leak, are outside what it sees. ## The two modes - **Report mode**: Report mode records each flow of untrusted data into a sink as an event on the dashboard and lets the program keep running. - **Report and Raise mode**: Report and Raise mode records the event and also stops the operation, raising a Python RuntimeError instead of running it. Every machine starts in Report. Blocking is turned on with Raise rules that name the operations to stop, and Raise must be enabled for the account. The dashboard and the user guide use these two names; the home page describes them as "log it" and "stop and log it". ## Proof - EyalSec surfaced the data flows behind two severe SQL-injection vulnerabilities in Django, both unauthenticated: **CVE-2024-42005** (CVSS 7.3 High per NVD; CISA rates it 9.8 Critical), SQL injection through crafted JSON object keys used as column aliases in `QuerySet.values()` / `values_list()` on models with a `JSONField`; and **CVE-2025-57833** (CVSS 8.1 High per NVD), SQL injection through `FilteredRelation` column aliases in `annotate()` / `alias()` with `**kwargs` expansion. - NVD rates both High; only CISA's score puts CVE-2024-42005 in the Critical band. Describe them as two severe CVEs, or quote each score with its source. The engine surfaced the data flows; the founder, Eyal Gabay, recognized them, reproduced them by hand and reported them to the Django security team. It was not an autonomous discovery. Both were fixed, and Django's security advisories credit "Eyal Gabay of EyalSec". - Both flaws were in the framework, not in application code: the same check covers third-party libraries. ## How to buy - Book a live demo at https://eyalsec.com/contact; EyalSec runs es-python on your own code with you and then sets up your account. - Pricing is metered: a monthly rate per machine plus a rate per detection, with lower rates as volume grows and a multiplier when blocking (Raise) is enabled. There are no plans, seats, base fee or minimum. The calculator at https://eyalsec.com/pricing prices any machine and detection count. - There is no free tier and no trial. ## About EyalSec - EyalSec is the company behind es-python and es-chromium. It is founder-led, early-stage and based in Petah Tikva, Israel. It has no public customer names, case studies or usage statistics, and nothing on its site should be read as one. - Founder: Eyal Gabay. He recognized, reproduced and reported the two Django SQL-injection CVEs above; Django's security advisories credit "Eyal Gabay of EyalSec". - Legal form: Eyal Gabay trading as "EyalSec", a sole proprietor (osek murshe) registered in Israel, no. 211868450; the terms at https://eyalsec.com/legal/terms carry the details. - Hosting: the service runs on Amazon Web Services in the il-central-1 (Israel) region. EyalSec holds no SOC 2 or ISO 27001 certificate; its security measures are published at https://eyalsec.com/legal/security. - Contact: sales@eyalsec.com for demos and pricing, support@eyalsec.com for help, security@eyalsec.com for vulnerability reports, or the form at https://eyalsec.com/contact. ## How it works You install es-python next to your normal Python and start your program with `es-python` in place of `python`; nothing in your code has to change. While the program runs, es-python watches for one specific danger: untrusted data from the outside world reaching a dangerous operation. 1. **Sources.** Data is marked untrusted the moment it enters through a source you switched on: a network socket, a file, standard input, environment variables and command-line arguments, rows read back from a database, or files another user can change (data read from them and code loaded from them). 2. **Tracking.** The mark follows the value as the program copies it, joins it with other text, slices it, decodes it and passes it to other functions, including into third-party libraries. 3. **Sinks.** At a dangerous operation (a shell command, a database query, eval or exec, deserialization, a file path, a dynamic import, an outbound connection) es-python checks whether the value carries the mark. If it does, it records an event and, under a Raise rule, stops the operation with a Python `RuntimeError`. A sink on its own is not a bug: programs run commands and open files all the time. It becomes a finding when the value reaching it contains untrusted data. A correctly parameterized SQL query never fires, because the untrusted value is a bound parameter and never becomes part of the SQL text. ## What an event contains, and what is sent By default your source code stays on the machine; only detection events are sent to the dashboard. Each event carries the operation that fired, the untrusted value, where it came from and the stack trace. Three settings add context, each set per machine or account: - **Command line** (on by default): the command that started the program. - **Environment variables** (off by default): the program's environment at startup. - **Source code** (off by default): two short windows of your source, the line that received the untrusted data and the line that used it, five lines either side. Only those windows are read, never a whole file. In the command line and environment, values whose names look like secrets (passwords, tokens, keys, credentials, the password part of a URL) are replaced with `[masked]` on the machine before anything is sent, and masked again on arrival. Masking goes by name, so a secret under an ordinary name is not masked, and source snippets are not masked. ## Glossary - **Taint tracking**: Taint tracking is a runtime technique that marks data from untrusted sources and follows it through every transformation until it reaches a dangerous operation. - **Untrusted data**: Untrusted data, also called tainted data or taint, is any data a program received from outside, such as a network request or a file. - **Source**: A source is a place where untrusted data enters a program, such as a network socket, a file, standard input or an environment variable. - **Sink**: A sink is a dangerous operation where untrusted data can do harm, such as running a shell command, sending a database query or evaluating code. - **Event**: An event is EyalSec's record that untrusted data reached a sink, with the operation, the line of code, the value and its origin. - **es-python**: es-python is the EyalSec Python runtime: you run your programs with es-python instead of python, and it reports untrusted data reaching a sink. - **Report mode**: Report mode records each flow of untrusted data into a sink as an event on the dashboard and lets the program keep running. - **Report and Raise mode**: Report and Raise mode records the event and also stops the operation, raising a Python RuntimeError instead of running it. ## Runtime detection vs. static scanning EyalSec watches your program as it really runs, instead of reading source before it runs like a static scanner or linter. - **What it analyzes**: EyalSec, your program as it really runs, including installed libraries; static scanning, source text before it runs. - **What a finding means**: EyalSec, untrusted data really reached a dangerous operation, with the trace to show it (a confirmed flow, which you still judge: your code may restrict the value first); static scanning, a path that might be reachable, including paths that never run. - **Response**: EyalSec can stop the operation at runtime (Report and Raise mode); static scanning reports only. - **Cost**: EyalSec adds runtime overhead that depends on the sources you switch on (none with every source off); static scanning runs outside production. - **Setup**: EyalSec, run your app with es-python, with no code changes; static scanning, configure and tune the scanner. ## What EyalSec adds next to other tools EyalSec checks the operation the program actually performs, with the data that really reached it. Each tool category below does its job in a different place; es-python adds the flows beyond it: - **WAF / edge filtering** pattern-matches HTTP at the network boundary. Beyond it: a payload that is encoded on the wire and decoded inside the app, an injection split across two harmless-looking fields, and input that never arrives over HTTP, such as a file or a queue message. es-python checks the value at the operation, whatever its channel. - **SAST** reads source code without running it. It reports possible paths before anything runs; beyond it are flows decided at runtime (a payload decoded at runtime, a function picked from a dictionary or by `getattr`, second-order data). es-python reports flows that happened. - **SCA / dependency scanning** matches installed versions against known CVEs. Beyond it: a flaw in your own code, a flaw with no advisory yet, and whether attacker data ever reaches the vulnerable function. es-python reports when it does. - **RASP and IAST agents** are often scoped to the HTTP request lifecycle, to known-vulnerable components, or to the libraries they instrument, so a background worker with no HTTP request, or a hand-written parser, can fall outside them. es-python follows the data in any Python process, including workers. - **DAST and fuzzing** probe the running app from outside. A successful injection that returns normally (no crash, no different response) looks like a pass. es-python reports the flow itself, with no crash needed. - **EDR** watches the host for malicious behavior after it happens. In Report and Raise mode es-python stops the operation where the untrusted data arrives, for most operations before it runs. - **Sandboxes (seccomp, gVisor)** decide per system call, so a legitimate command and an injected one look alike. es-python stops only the call that carries untrusted data. EyalSec does not replace every control: authorization checks, CSRF protection, rate limiting and dependency inventory still belong to other tools. ## FAQ ### What is EyalSec? EyalSec makes es-python, a secure Python runtime that runs your code unchanged and reports or blocks untrusted data reaching a dangerous operation. ### Do I need to change my code to use EyalSec? No: you start your application with es-python instead of python, and your code and packages run unchanged. Your regular python stays installed. ### Which Python versions and frameworks does es-python support? es-python supports Python 3.9, 3.10, 3.11, 3.12, 3.13 and 3.14 on Linux (x86_64 and aarch64), with any framework, including Django, Flask and FastAPI. ### What vulnerabilities does EyalSec detect? EyalSec detects attacker data reaching a dangerous operation: SQL injection, command injection, code injection, deserialization, path traversal and SSRF. Not flaws without a data flow. ### Does EyalSec cover third-party dependencies? Yes: EyalSec checks the dangerous operation itself, so a flaw inside Django or any installed library is covered like your own code, CVE or not. ### How much does es-python slow an application down? It depends on which sources you switch on: with none, es-python runs at regular Python speed. Measure it on your own application. ### Does EyalSec produce false positives? An EyalSec event is a confirmed flow: untrusted data really reached a dangerous operation. You still judge whether it is exploitable; parameterized queries never fire. ### Does EyalSec send my source code anywhere? Not by default: your source code stays on the machine. Events carry the operation, the untrusted value, its origin and the stack trace. ### What is the difference between Report and Report and Raise? Report records each event and lets the program keep running; Report and Raise also stops the operation with a Python RuntimeError. ### How is EyalSec different from a WAF, SAST, SCA or RASP? EyalSec checks the operation your program performs, with the data that reached it. WAF, SAST, SCA and RASP see the request, source or packages. ### How is EyalSec priced? EyalSec is priced by usage: a monthly rate per machine plus a rate per detection. There is no free tier and no trial. ### How do I get started with EyalSec? Book a live demo: EyalSec runs es-python on your own code with you, then sets up your account and gives you a one-line install command. ## Pages - [Home](https://eyalsec.com/): what EyalSec does, the two modes, the Django CVEs, the comparison with other tool categories, the glossary and the FAQ - [Pricing](https://eyalsec.com/pricing): the metered pricing calculator: per machine and per detection, with the Raise multiplier - [Security](https://eyalsec.com/security): how EyalSec handles your data and secures the service - [Vulnerability disclosure](https://eyalsec.com/security/disclosure): how to report a security problem in EyalSec, the scope, the safe harbour and our response times - [Security advisories](https://eyalsec.com/security/advisories): EyalSec's own security advisories and their CVE records - [Contact](https://eyalsec.com/contact): book a live demo; sales, support and security disclosures - [Documentation](https://eyalsec.com/docs): the user guide: install es-python, read events, configure sources and rules, and use the API - [Research](https://eyalsec.com/research): articles from EyalSec on Python application security and runtime detection - [Research feed](https://eyalsec.com/research/feed.xml): the feed of new research articles - [OpenAPI description](https://eyalsec.com/openapi.json): the customer JSON API as an OpenAPI 3.1 document, also at /openapi.yaml - [Glossary](https://eyalsec.com/glossary): one-sentence definitions: taint tracking, source, sink, sanitizer, the two modes, every vulnerability class, and WAF, RASP, SAST, DAST, SCA and IAST - [About](https://eyalsec.com/about): who EyalSec is, the founder, and what es-python and es-chromium observe and report - [Press kit](https://eyalsec.com/press): boilerplate, fact sheet, founder bio, product one-liners, logos and brand colours - [Changelog](https://eyalsec.com/changelog): what changed in the dashboard, the site and es-python, newest first - [Changelog feed](https://eyalsec.com/changelog/feed.xml): the Atom feed of the changelog - [EyalSec in Hebrew](https://eyalsec.com/he): the landing page in Hebrew ## Research articles - [Insecure deserialization beyond pickle: yaml.load, marshal, shelve, jsonpickle, Django signed cookies](https://eyalsec.com/research/insecure-deserialization-beyond-pickle): Python deserialization risks beyond pickle: yaml.load loaders, marshal, shelve, jsonpickle, json object_hook and what a Django signed cookie protects. (2026-10-12) - [Path traversal in Python file handling: os.path.join, pathlib, send_file and zip extraction](https://eyalsec.com/research/path-traversal-in-python-file-handling): Where path traversal hides in Python: os.path.join, pathlib, Flask send_file, Django FileResponse and zip or tar extraction, with a check that works. (2026-10-12) - [ReDoS in Python: catastrophic backtracking in re and how to find it at runtime](https://eyalsec.com/research/redos-in-python-catastrophic-backtracking): How catastrophic backtracking happens in Python's re module, measured on a real pattern, plus the fixes that work and a way to find ReDoS at runtime. (2026-10-12) - [Running your pytest suite under the EyalSec Python runtime: what it reports and how to read an event](https://eyalsec.com/research/running-your-pytest-suite-under-the-eyalsec-python-runtime): A tutorial: run pytest under es-python, choose the sources, mark test input as untrusted, read each block of an event, and gate a CI build on the result. (2026-10-12) - [Securing a Django app against SQL injection: raw(), extra(), RawSQL, cursor.execute and the ORM edge cases](https://eyalsec.com/research/securing-a-django-app-against-sql-injection): Hardening a Django app against SQL injection: raw(), extra(), RawSQL, cursor.execute, the ORM edge cases parameters cannot cover, and a test. (2026-10-12) - [Server-side template injection in Jinja2 and Django templates: where user input meets the renderer](https://eyalsec.com/research/server-side-template-injection-in-jinja2-and-django-templates): How server-side template injection reaches Jinja2 and Django templates in Python, what each engine exposes, and how to keep user input in the context. (2026-10-12) - [SSRF in Python services: requests, httpx, urllib and the cloud metadata endpoint](https://eyalsec.com/research/ssrf-in-python-services): How SSRF reaches requests, httpx and urllib in Python, why the cloud metadata endpoint raises the stakes, and a destination check that holds up. (2026-10-12) - [What a RASP, an IAST and a WAF each see of the same attack: a comparison by category](https://eyalsec.com/research/what-a-rasp-an-iast-and-a-waf-each-see-of-the-same-attack): One SQL injection, delivered three ways, seen from a WAF, an IAST sensor and a RASP agent: what each observes, and what a view at the operation adds. (2026-10-12) - [Command injection through subprocess in Python: shell=True, shlex and argument injection](https://eyalsec.com/research/command-injection-through-subprocess-in-python): How command injection reaches Python subprocess calls: shell=True, shlex mistakes, argument injection, and cases that look safe but are not. (2026-10-07) - [The Django column-alias SQL injection story: CVE-2024-42005 and CVE-2025-57833](https://eyalsec.com/research/django-column-alias-sql-injection-cve-2024-42005-cve-2025-57833): What the two severe Django column-alias SQL injection CVEs are, how each reaches the SQL, and why they live in a dependency rather than your code. (2026-10-07) - [SQL injection in Django, Flask and FastAPI in 2026: the patterns that still ship](https://eyalsec.com/research/sql-injection-in-django-flask-and-fastapi): The Python SQL injection patterns still shipping in 2026, from raw() and text() to ORDER BY and alias injection, and the fix for each. (2026-10-07) - [Taint tracking explained for Python developers](https://eyalsec.com/research/taint-tracking-explained-for-python-developers): Taint tracking follows untrusted data from where it enters to where it is used. Sources, propagation, sinks, sanitizers, and its honest limits. (2026-10-07) - [Why pickle.loads is dangerous, and where it hides in Python apps](https://eyalsec.com/research/where-pickle-deserialization-hides-in-python-apps): Loading untrusted pickle data can run code. Where pickle hides in Python apps (caches, queues, sessions, model files) and safer options. (2026-10-07) - [Why a WAF cannot see a SQL injection decoded inside the app](https://eyalsec.com/research/why-a-waf-cannot-see-a-sql-injection-decoded-inside-the-app): An edge filter matches the request on the wire. If the payload is decoded, reassembled or arrives off-HTTP, there is no signature to match. (2026-10-11) ## Vulnerability classes - [Vulnerability classes](https://eyalsec.com/vulnerabilities): every vulnerability class EyalSec detects, one page each - [SQL injection in Python](https://eyalsec.com/vulnerabilities/sql-injection): SQL injection in Python with sqlite3, psycopg, Django raw() and SQLAlchemy text(): the hard-to-spot variants, and how es-python reports it. - [Command injection in Python](https://eyalsec.com/vulnerabilities/command-injection): OS command injection in Python with os.system, subprocess shell=True and paramiko: the variants hardest to spot, and the es-python event. - [Code injection in Python](https://eyalsec.com/vulnerabilities/code-injection): Code injection in Python through eval(), exec(), dynamic imports and getattr() dispatch, why it is hard to see, and how es-python reports it. - [Server-side template injection (SSTI) in Python](https://eyalsec.com/vulnerabilities/server-side-template-injection): Server-side template injection in Python with Jinja2, Flask render_template_string, Django, Mako and Tornado, and how es-python reports it. - [Insecure deserialization in Python](https://eyalsec.com/vulnerabilities/insecure-deserialization): Insecure deserialization in Python with pickle, PyYAML yaml.load and jsonpickle, why it is remote code execution, and how es-python reports it. - [Path traversal in Python](https://eyalsec.com/vulnerabilities/path-traversal): Path traversal in Python with open(), os.path.join, send_file and zip/tar extraction, the subtle variants, and how es-python reports it. - [Server-side request forgery (SSRF) in Python](https://eyalsec.com/vulnerabilities/ssrf): SSRF in Python with requests, urllib and sockets, why cloud metadata makes it critical, and how es-python reports a tainted internal destination. - [NoSQL injection in Python](https://eyalsec.com/vulnerabilities/nosql-injection): NoSQL injection in Python with PyMongo and Redis Lua, why operator injection differs from SQLi, and how es-python reports a tainted query. - [Untrusted code loading in Python](https://eyalsec.com/vulnerabilities/untrusted-code-loading): Loading Python code from a file another user can write, or a module shadowing the standard library, why it is RCE, and how es-python reports it. - [Regular expression denial of service (ReDoS) in Python](https://eyalsec.com/vulnerabilities/redos): ReDoS in Python when attacker input meets a backtracking regex, why the pattern or the input can be the attacker's, and how es-python reports it. - [Hardcoded credentials in Python](https://eyalsec.com/vulnerabilities/hardcoded-credentials): Hardcoded credentials in Python source, why a secret written in code leaks and cannot be rotated, and how es-python reports one leaving the program. - [Weak randomness in Python](https://eyalsec.com/vulnerabilities/weak-randomness): Weak randomness in Python when random or uuid1 generates a security token, why it is predictable, and how es-python reports it at the point of use. - [Django vulnerability classes](https://eyalsec.com/vulnerabilities/for/django): The vulnerability classes es-python reports in running Django applications: how each arises, a vulnerable snippet, its fix and the event. - [Flask vulnerability classes](https://eyalsec.com/vulnerabilities/for/flask): The vulnerability classes es-python reports in running Flask applications: how each arises, a vulnerable snippet, its fix and the event. - [FastAPI vulnerability classes](https://eyalsec.com/vulnerabilities/for/fastapi): The vulnerability classes es-python reports in running FastAPI applications: how each arises, a vulnerable snippet, its fix and the event. - [SQL injection in Django](https://eyalsec.com/vulnerabilities/sql-injection/django): Where SQL injection still enters a Django app: raw(), extra(), RawSQL, cursor.execute() and request keys expanded into the ORM, with fixes. - [SQL injection in Flask](https://eyalsec.com/vulnerabilities/sql-injection/flask): Flask ships no database layer, so SQL injection in a Flask app depends on what you add: sqlite3, Flask-SQLAlchemy and text(). Patterns and fixes. - [SQL injection in FastAPI](https://eyalsec.com/vulnerabilities/sql-injection/fastapi): FastAPI validates parameter types, not SQL. Where injection enters async endpoints (asyncpg, text(), sort parameters) and the idiomatic fixes. - [SQL injection in SQLAlchemy](https://eyalsec.com/vulnerabilities/sql-injection/sqlalchemy): SQLAlchemy binds values in expressions, but text(), literal_column() and string order_by arguments take SQL as written. How each goes wrong. - [SQL injection in psycopg](https://eyalsec.com/vulnerabilities/sql-injection/psycopg): psycopg binds values passed as the second argument to execute(). The mistakes that defeat it (% formatting, quoted placeholders) and sql.Identifier. - [Command injection in Django](https://eyalsec.com/vulnerabilities/command-injection/django): Command injection in Django views, management commands and Celery tasks: shell=True, call_command() with request data, and upload names as options. - [Command injection in Flask](https://eyalsec.com/vulnerabilities/command-injection/flask): Command injection in Flask webhooks and upload handlers: shell=True with request JSON, git and converter arguments, and why quoting is not enough. - [Command injection in FastAPI](https://eyalsec.com/vulnerabilities/command-injection/fastapi): Command injection in async FastAPI services: asyncio.create_subprocess_shell, background tasks that run tools, and Pydantic fields used as arguments. - [Code injection in Django](https://eyalsec.com/vulnerabilities/code-injection/django): Code injection in Django: import_string() on request data, getattr() dispatch in views, setattr() mass assignment and eval() in admin tooling. - [Code injection in Flask](https://eyalsec.com/vulnerabilities/code-injection/flask): Code injection in Flask: eval() behind calculator and filter endpoints, import_string() plugin loading, and the debugger console left on in production. - [Code injection in FastAPI](https://eyalsec.com/vulnerabilities/code-injection/fastapi): Code injection in FastAPI: Pydantic ImportString fields on request models, importlib plugin loading by name, and eval() behind typed-looking endpoints. - [Server-side template injection (SSTI) in Django](https://eyalsec.com/vulnerabilities/server-side-template-injection/django): SSTI in Django: Template() and from_string() on request or database text, what the Django template language exposes, and user-editable templates. - [Server-side template injection (SSTI) in Flask](https://eyalsec.com/vulnerabilities/server-side-template-injection/flask): SSTI in Flask: render_template_string() with request data in the source, custom error pages that echo the path, and what the Jinja sandbox covers. - [Server-side template injection (SSTI) in FastAPI](https://eyalsec.com/vulnerabilities/server-side-template-injection/fastapi): SSTI in FastAPI: where Jinja2Templates is safe, and how env.from_string() or jinja2.Template on tenant-written email bodies lets a request write code. - [Insecure deserialization in Django](https://eyalsec.com/vulnerabilities/insecure-deserialization/django): Where pickle sits in a Django project: the cache framework, legacy PickleSerializer sessions and Celery messages, and how to keep it away from users. - [Insecure deserialization in Flask](https://eyalsec.com/vulnerabilities/insecure-deserialization/flask): Flask's own session cookie is signed JSON, so unsafe deserialization in Flask apps comes from yaml.load() on uploads and pickle in homemade cookies. - [Insecure deserialization in FastAPI](https://eyalsec.com/vulnerabilities/insecure-deserialization/fastapi): FastAPI parses bodies into Pydantic models, so unsafe deserialization appears around it: uploaded model files, pickle task queues and binary endpoints. - [Path traversal in Django](https://eyalsec.com/vulnerabilities/path-traversal/django): Path traversal in Django download views: FileResponse with a request path, the path converter, static.serve in production, and upload file names. - [Path traversal in Flask](https://eyalsec.com/vulnerabilities/path-traversal/flask): Path traversal in Flask: send_file() with a joined path, when send_from_directory() and safe_join() protect you, and upload names without secure_filename. - [Path traversal in FastAPI](https://eyalsec.com/vulnerabilities/path-traversal/fastapi): Path traversal in FastAPI: the :path convertor feeding FileResponse, UploadFile.filename written to disk, and when mounting StaticFiles is enough. - [Server-side request forgery (SSRF) in Django](https://eyalsec.com/vulnerabilities/ssrf/django): SSRF in Django: why URLField and URLValidator accept loopback addresses, webhook URLs stored in models, and redirects that requests follows by default. - [Server-side request forgery (SSRF) in Flask](https://eyalsec.com/vulnerabilities/ssrf/flask): SSRF in Flask proxy and preview endpoints: a url query parameter passed to requests, redirects followed by default, and a destination check that holds. - [Server-side request forgery (SSRF) in FastAPI](https://eyalsec.com/vulnerabilities/ssrf/fastapi): SSRF in FastAPI: what Pydantic HttpUrl does and does not check, webhook registration endpoints, httpx redirect defaults and a resolver-based fix. - [NoSQL injection in Flask](https://eyalsec.com/vulnerabilities/nosql-injection/flask): NoSQL injection in Flask with PyMongo: request.get_json() values used as filters, operator objects such as $ne in a login query, and $where strings. - [NoSQL injection in FastAPI](https://eyalsec.com/vulnerabilities/nosql-injection/fastapi): NoSQL injection in FastAPI with MongoDB: how typed Pydantic fields stop operator injection, and the dict and Any fields that let it back in. - [Regular expression denial of service (ReDoS) in Django](https://eyalsec.com/vulnerabilities/redos/django): ReDoS in Django: re_path() routes, RegexValidator on form fields and user-supplied search patterns, with bounded rewrites that match in linear time. - [Regular expression denial of service (ReDoS) in FastAPI](https://eyalsec.com/vulnerabilities/redos/fastapi): ReDoS in FastAPI: Pydantic's default regex engine does not backtrack, and the settings and hand-written re calls that bring backtracking back. ## Documentation - [What is EyalSec?](https://eyalsec.com/docs/what-is-eyalsec): the idea, the three pieces (agents, dashboard, API) and the products - [How EyalSec works](https://eyalsec.com/docs/how-it-works): untrusted data, sources, sinks and events, and the two modes - [Quick start](https://eyalsec.com/docs/quick-start): from a new account to the first event - [Products](https://eyalsec.com/docs/products): es-python and es-chromium, and how each is enabled - [Install es-python on a machine](https://eyalsec.com/docs/install): the one-line install command and what it does - [Running es-python](https://eyalsec.com/docs/running-es-python): running your code, Python versions side by side, and speed - [Packages and libraries](https://eyalsec.com/docs/packages): how es-python uses your installed packages and virtual environments - [Taint sources](https://eyalsec.com/docs/taint-sources): every source, what it marks and how to switch it on - [Report and Raise](https://eyalsec.com/docs/report-and-raise): recording versus blocking, and how to turn blocking on - [Event context](https://eyalsec.com/docs/event-context): what is sent with each event and how secrets are masked - [Rules](https://eyalsec.com/docs/rules): show, hide, drop or block events by pattern - [Events](https://eyalsec.com/docs/events): the events list, counts and severity - [es-chromium](https://eyalsec.com/docs/es-chromium): the EyalSec browser for DOM XSS testing - [API overview](https://eyalsec.com/docs/api): the JSON API and API keys - [Glossary](https://eyalsec.com/docs/glossary): every term in the dashboard and the guide - [FAQ](https://eyalsec.com/docs/faq): short answers with links to the details ## Optional - [Create an account](https://eyalsec.com/docs/register): This page explains how to create an EyalSec account, verify your email address, accept an invitation, and what a new account can do before a plan is set up. - [Sign in and sign out](https://eyalsec.com/docs/sign-in): This page explains how to sign in with your username and password or with Google, what the two-factor step at sign-in looks like, what happens after too many... - [Forgot password](https://eyalsec.com/docs/forgot-password): This page explains how to reset a forgotten password with a code sent to your email, and the rules every EyalSec password follows. - [Settings](https://eyalsec.com/docs/settings): This page explains the Settings pages, where you manage your own account, and covers the recovery email, the display time zone, and why some changes ask for... - [Password and sessions](https://eyalsec.com/docs/sessions): This page explains how to change your password from Settings and how to choose how long you stay signed in without using the dashboard. - [Two-factor authentication](https://eyalsec.com/docs/two-factor): This page explains how to protect your account with a second step at sign-in: an authenticator app, one-time backup codes, and codes sent by email. - [API keys](https://eyalsec.com/docs/api-keys): This page explains how to create, view, replace and revoke the API key that lets your own scripts and tools use the EyalSec API without signing in through the... - [Delete your account](https://eyalsec.com/docs/delete-account): This page explains the Danger zone page in Settings and what happens when you permanently delete your EyalSec account. - [Plans and limits](https://eyalsec.com/docs/plans): This page explains what your EyalSec plan sets: which products you have, how many machines you can add, how many events each list shows, and whether you can... - [Machines](https://eyalsec.com/docs/machines): What a machine is, how the Machines page lists your machines, what each status means and what the badges on a machine's row tell you. - [Add a machine](https://eyalsec.com/docs/add-machine): How to add a machine on the Machines page: choosing the product, naming the machine, and for es-python choosing whether its build is detected on the host (the... - [Install es-chromium](https://eyalsec.com/docs/install-other-products): How to install es-chromium, the EyalSec browser, on a PC. - [Manage machines](https://eyalsec.com/docs/manage-machines): How to look after a machine once it exists: setting a missing OS, renaming, reinstalling to take a newer build, configuring what it watches, uninstalling... - [Configure a machine](https://eyalsec.com/docs/configure): The Configure window is where you choose what a machine watches for and what happens to the events it finds. - [Unique event](https://eyalsec.com/docs/unique-event): The Unique event setting decides when a new detection opens a new row in your events list and when it only adds to the count of an existing one. - [Outbound connections](https://eyalsec.com/docs/outbound-connections): The Outbound connections policy lets you decide which addresses and URLs your es-python programs may connect to. - [Web protection](https://eyalsec.com/docs/web-protection): Web protection is a set of checks es-python runs on every request your web application receives: who sent it, how often, whether it is framed in a way that can... - [Event detail](https://eyalsec.com/docs/event-detail): Clicking an event opens it in place and shows the full story: what happened, why it matters, what to do, and the evidence behind it. - [Filtering events](https://eyalsec.com/docs/filtering): The toolbar above each events list narrows what you see: by time, machine, severity, source, label, tag and more. - [Advanced search](https://eyalsec.com/docs/advanced-search): The Advanced search panel under the events toolbar holds the time range, the scope, the condition builder and your saved searches. - [Suppressed events](https://eyalsec.com/docs/suppressed-events): EyalSec hides events it can prove are harmless, without deleting them. This page explains what suppression is, how to see suppressed events, and how it affects... - [Triage](https://eyalsec.com/docs/triage): Triage is working through your events: marking what you have looked at, writing down what you decided, and hiding what you do not need to see again. - [Saved searches](https://eyalsec.com/docs/saved-searches): A saved search stores the whole state of an events list under a name, so you can come back to it in one click. - [Runs](https://eyalsec.com/docs/runs): A run is one start of a program under es-python, from launch to exit. This page explains runs, how to narrow the events list to a single run with the run... - [Events map](https://eyalsec.com/docs/events-map): The Map layout draws your es-python events as a picture: where the untrusted data came from, which files it was read from, and which sinks it reached. - [Exporting events](https://eyalsec.com/docs/export): You can download your events as a CSV or JSON file through the EyalSec API. This page explains how, and how to export exactly what the events page shows. - [Filters](https://eyalsec.com/docs/filters): The Filters page holds the standing settings for one product: the taint sources and rules that apply to every machine, what you have blocked from view, and... - [Rule templates](https://eyalsec.com/docs/rule-templates): A rule template is a named set of rules you can copy into your global rules or into one machine in a single step. - [Dashboard](https://eyalsec.com/docs/dashboard): What each card and panel on the dashboard shows, how the numbers are counted, and what to do when the dashboard is empty. - [Activity log](https://eyalsec.com/docs/activity-log): What the Activity page records, what each column means, how to filter it and load older entries, and how long entries are kept. - [es-python command line](https://eyalsec.com/docs/es-python-cli): Every EyalSec option you can give es-python on the command line, and the environment variable that does the same thing, in one reference. - [On the machine](https://eyalsec.com/docs/on-machine): What the es-python installer leaves on your computer besides the runtime: the on-machine manual, the build records, the eyalsec helper command, and a context... - [Run es-python in CI](https://eyalsec.com/docs/ci): How to run your test suite under es-python in a CI pipeline and fail the build when the run reports serious findings, with ready-to-paste GitHub Actions and... - [Use es-python in a container](https://eyalsec.com/docs/containers): How to build es-python into a container image with a Dockerfile, so that your program runs under es-python whenever the container starts, and what to keep in... - [README badges](https://eyalsec.com/docs/readme-badges): Two badges you can add to a project's README to show readers that it runs, or is tested, under es-python, with copy-paste snippets for Markdown, HTML and... - [Events API](https://eyalsec.com/docs/api-events): These endpoints read your events: the filtered list, one event's full detail, the runs that produced them, counts and statistics, exports, the es-python map,... - [Machines API](https://eyalsec.com/docs/api-machines): These endpoints list your machines and do what the Machines page does: add, rename and delete machines, set their OS, and get their install and uninstall... - [CI API](https://eyalsec.com/docs/api-ci): These endpoints let a CI pipeline that holds only an API key install es-python on its runner and then ask what the test run reported. - [Filters, rules and taint API](https://eyalsec.com/docs/api-rules): These endpoints manage what the dashboard shows and what your machines watch: event filters, machine rules, taint source settings, and the definition of what... - [Rule templates API](https://eyalsec.com/docs/api-templates): These endpoints list, create, change and apply rule templates, and save a scope's current rules as a new template. - [Labels and notes API](https://eyalsec.com/docs/api-labels): These endpoints manage your triage labels, attach them to events, and write your note on an event. - [Activity and account API](https://eyalsec.com/docs/api-account): These endpoints read your activity log and manage your own account: profile, time zone, recovery email, password, two-factor sign-in and API key. - [Agent endpoints](https://eyalsec.com/docs/api-agents): These are the endpoints the EyalSec agents on your machines call by themselves: to report events, to fetch their configuration, and to install. - [Troubleshooting](https://eyalsec.com/docs/troubleshooting): Find the symptom you are seeing and follow the steps under it, from the most common cause to the least. - [Getting help](https://eyalsec.com/docs/help): How to reach EyalSec for support or for changes to your plan, and what to include so we can help quickly. ## Research articles, full text Every article under https://eyalsec.com/research, newest first, as published. ### Insecure deserialization beyond pickle: yaml.load, marshal, shelve, jsonpickle, Django signed cookies URL: https://eyalsec.com/research/insecure-deserialization-beyond-pickle | Published: 2026-10-12 | Updated: 2026-10-12 | Author: EyalSec Research | Tags: python, insecure-deserialization, code-injection, django Pickle is the best-known unsafe deserializer in Python, and it is not the only one. `yaml.load` with a permissive loader, `marshal`, `shelve` and `jsonpickle` can all build objects or run code from bytes an attacker shaped, and a signed cookie is only as safe as the serializer behind the signature. The practical rule: for anything that crosses a trust boundary, use a format that can only carry data (JSON, or YAML through `safe_load`), validate its shape, and treat a signature as proof of who wrote the bytes, not as a reason to load them with a format that can run code. This article takes each loader in turn, with the documented warning and the replacement. The weakness is [CWE-502: Deserialization of Untrusted Data](https://cwe.mitre.org/data/definitions/502.html), part of OWASP's [A08:2025 Software or Data Integrity Failures](https://top10.owasp.org/2025/A08_2025-Software_or_Data_Integrity_Failures/). For pickle itself, and where it hides inside caches, queues and model files, see [our earlier article](https://eyalsec.com/research/where-pickle-deserialization-hides-in-python-apps). #### The question to ask of any loader {#question} A deserializer is one of two kinds. A data-only loader builds strings, numbers, lists and dictionaries, and nothing the input says can make it do more. An object loader builds instances of classes named in the input, which means the input chooses which code runs during the load. The second kind is the risk, and the format's appearance says nothing about which kind it is: YAML and JSON both look like harmless text. So for every load in a codebase, two questions: which kind of loader is this, and could anyone outside your trust boundary have influenced the bytes, now or at some earlier point? #### yaml.load: the loader is the decision {#yaml} PyYAML ships several loaders, and the difference between them is exactly the data-only versus object distinction. | Call | Builds | Use on untrusted input | |------|--------|------------------------| | `yaml.safe_load(data)` / `Loader=yaml.SafeLoader` | scalars, lists, dictionaries | yes | | `yaml.full_load(data)` / `Loader=yaml.FullLoader` | most YAML tags, with restrictions | no | | `yaml.unsafe_load(data)` / `Loader=yaml.UnsafeLoader` or `yaml.Loader` | arbitrary Python objects | no | The [PyYAML documentation](https://pyyaml.org/wiki/PyYAMLDocumentation) says of the full power of `yaml.load` that it is "as powerful as `pickle.load` and so may call any Python function". The project deprecated calling `yaml.load` without an explicit `Loader` for that reason, as its [deprecation note](https://github.com/yaml/pyyaml/wiki/PyYAML-yaml.load(input)-Deprecation) explains. `FullLoader` deserves a specific mention, because its name suggests a sensible default. It was introduced as a safer middle ground, and two code execution vulnerabilities were later found in it: [CVE-2020-1747](https://nvd.nist.gov/vuln/detail/CVE-2020-1747) and [CVE-2020-14343](https://nvd.nist.gov/vuln/detail/CVE-2020-14343), both rated Critical by NVD, both through `full_load` or `FullLoader` on untrusted input. The PyYAML maintainers fixed both, and the deprecation note still advises against `FullLoader` for untrusted data. The lesson generalises: a restricted object loader is a list of exceptions that has to stay complete forever, while a data-only loader has nothing to keep complete. ```python import yaml def read_config(text: str) -> dict: data = yaml.safe_load(text) if not isinstance(data, dict): raise ValueError("expected a mapping at the top level") return data ``` Search for `yaml.load(` and read the `Loader` argument on every hit. Also search for `yaml.Loader`, `UnsafeLoader`, `FullLoader`, `unsafe_load` and `full_load`, and for libraries that wrap PyYAML and choose the loader for you. #### marshal: an internal format {#marshal} `marshal` is the format Python uses for compiled bytecode files. The documentation is unambiguous: "The marshal module is not intended to be secure against erroneous or maliciously constructed data. Never unmarshal data received from an untrusted or unauthenticated source" ([marshal](https://docs.python.org/3/library/marshal.html)). It turns up in application code for two reasons: it is fast, and it can serialize code objects, which makes it attractive for shipping functions between processes. The second use is the dangerous one, because the loaded code object is then executed. If `marshal.loads` appears outside build tooling, ask what the bytes are and who can write them. There is no safe subset to switch to; use JSON for data and import real modules for code. #### shelve: pickle with a file name {#shelve} `shelve` gives you a persistent dictionary, and it stores the values with pickle. The documentation says so in a warning: "Because the shelve module is backed by pickle, it is insecure to load a shelf from an untrusted source. Like with pickle, loading a shelf can execute arbitrary code" ([shelve](https://docs.python.org/3/library/shelve.html)). The call that matters does not contain the word pickle: ```python import shelve def load_preferences(path: str) -> dict: with shelve.open(path, flag="r") as shelf: return dict(shelf) ``` This is safe while only your service writes `path`. It becomes a code execution path the moment the shelf file can be uploaded, restored from a user-supplied backup, synced from another tenant's storage, or written by a less privileged process on the same host. For a small persistent mapping, `sqlite3` with JSON text values does the same job with a data-only format. #### jsonpickle: JSON on the wire, objects on load {#jsonpickle} `jsonpickle` serializes arbitrary Python objects into JSON and rebuilds them on decode. The output is valid JSON, which is why it gets mistaken for a safe format. The project's own documentation opens with the warning: "The jsonpickle module is not secure. Only unpickle data you trust", and adds that "safer deserialization approaches, such as reading the raw JSON directly, may be more appropriate if you are processing untrusted data" ([jsonpickle documentation](https://jsonpickle.github.io/)). The encoded form carries type names, and `jsonpickle.decode()` imports and instantiates what they name. If the data came from outside, decode it with `json.loads` and build your own objects from the plain values: ```python import json from dataclasses import dataclass @dataclass class Job: name: str retries: int def job_from_message(body: bytes) -> Job: data = json.loads(body) if not isinstance(data, dict): raise ValueError("expected an object") return Job(name=str(data["name"]), retries=int(data["retries"])) ``` The construction is explicit: the code names the class, and the input supplies only values. #### json with hooks: safe until you add the dispatch {#object-hook} [`json.loads`](https://docs.python.org/3/library/json.html#json.loads) by itself is data-only. Its `object_hook` and `object_pairs_hook` parameters call your function for every decoded object, and that function can do anything. The risky pattern is a hook that picks a class from a field in the input: ```python import json REGISTRY = {} def revive_wrong(obj: dict): # the input names the class to build cls = REGISTRY.get(obj.get("__type__")) return cls(**obj["fields"]) if cls else obj def load_wrong(text: str): return json.loads(text, object_hook=revive_wrong) ``` With a small, explicit registry of harmless value classes this is manageable. With a registry filled by import-time decorators across the codebase, or one that falls back to importing a dotted name, it is jsonpickle again. Keep the registry closed and short, and validate the fields before constructing. #### Django signed cookies: what the signature covers {#django} Signed cookies and tokens are sometimes described as making deserialization safe. The signature answers one question (did we write these bytes?), and the serializer still decides what loading them can do. Django's history shows the difference. Its session framework once offered a pickle-based serializer; it was deprecated in 4.1 and removed in 5.0 ([release notes](https://docs.djangoproject.com/en/5.2/releases/4.1/)). With the cookie-based session backend, session data is "signed but not encrypted" ([cookie-based sessions](https://docs.djangoproject.com/en/5.2/topics/http/sessions/#using-cookie-based-sessions)): the client can read it and cannot change it without the key. Under the pickle serializer, anyone who obtained `SECRET_KEY` could sign a payload of their choice and have the server load it, turning a leaked key into code execution. Under the JSON serializer the same leak lets an attacker forge session contents, which is serious (it is an authentication bypass), and it is not code execution. Django's signing documentation makes the design choice explicit. `signing.dumps()` and `loads()` use JSON, and "JSON ensures that even if your SECRET_KEY is stolen an attacker will not be able to execute arbitrary commands by exploiting the pickle format" ([protecting complex data structures](https://docs.djangoproject.com/en/5.2/topics/signing/#protecting-complex-data-structures)). Three checks for a Django project: 1. `SESSION_SERIALIZER` is unset or the JSON serializer. A custom serializer class in settings needs reading. 2. Nothing passes a custom `serializer=` to `django.core.signing` that wraps pickle. 3. `SECRET_KEY` is out of the repository and rotated with `SECRET_KEY_FALLBACKS` when it has been exposed. The same reasoning applies to `itsdangerous` in Flask, to JWT libraries and to any HMAC-wrapped blob: sign data-only payloads. #### Finding the loads that matter {#finding} Start with a search: `yaml.load`, `full_load`, `unsafe_load`, `marshal.load`, `shelve.open`, `jsonpickle.decode`, `object_hook`, `object_pairs_hook`, `pickle.load`, and `serializer=` near signing code. Bandit reports several of these, including [B506](https://bandit.readthedocs.io/en/latest/plugins/b506_yaml_load.html) for `yaml.load`. Then comes the part that takes time: for each hit, working out where the bytes came from. A YAML document may arrive in a request, or be read from a repository a customer controls, or be pulled from a queue that another service fills. The load and the origin are often in different services, and the loader may sit inside a library. That backward trace is what es-python, the EyalSec Python runtime, does while the program runs. It follows untrusted data from where it enters and checks it at the operation itself, the load call, so it reports a deserializer only when bytes from an untrusted source actually reached it. The labels are `yaml unsafe load` for an unsafe PyYAML loader, `marshal loads`, `object hook` for `json.loads()` with a hook set, `unsafe deserialization` for `jsonpickle`, and `pickle loads` and `pickle load`. `yaml.safe_load()` and a plain `json.loads()` are not treated as deserialization sinks, so the corrected code above is silent ([what the event looks like](https://eyalsec.com/vulnerabilities/insecure-deserialization#event)). Two properties are specific to this class. EyalSec never [suppresses](https://eyalsec.com/docs/suppressed-events) a deserialization event on the value: for an injection it can sometimes prove the untrusted characters could not change the statement, and a serialized blob has no such safe form. And the check is at the load, so it applies to a worker reading a broker message as it does to a web request. As with every event, it shows that the flow happened, with the source and the call path; with data from a file only your service can write, the grade is low and the event says why. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) the load is stopped before the bytes are processed. See [How EyalSec works](https://eyalsec.com/docs/how-it-works) for the model and [pricing](https://eyalsec.com/pricing) for cost. #### Summary {#summary} | Loader | Kind | Replace with | |--------|------|--------------| | `yaml.load` with `Loader`, `FullLoader`, `UnsafeLoader` | object | `yaml.safe_load` plus shape validation | | `marshal.loads` | internal, no safety guarantee | JSON for data; real imports for code | | `shelve.open` | pickle | `sqlite3` with JSON values | | `jsonpickle.decode` | object | `json.loads` and explicit construction | | `json.loads(..., object_hook=...)` | depends on the hook | a closed registry, validated fields | | Signed cookie or token | as safe as its serializer | JSON payload under the signature | If you want to see which deserialization calls on your own services receive untrusted bytes, you can [book a live demo](https://eyalsec.com/contact). ### Path traversal in Python file handling: os.path.join, pathlib, send_file and zip extraction URL: https://eyalsec.com/research/path-traversal-in-python-file-handling | Published: 2026-10-12 | Updated: 2026-10-12 | Author: EyalSec Research | Tags: python, path-traversal, flask, django Path traversal in Python comes from building a filesystem path out of a client-supplied name and trusting the result. `os.path.join` and `pathlib` both discard the base directory when the name is absolute, neither removes `..` segments, and archive members bring their own names with them. The fix is the same everywhere: resolve the final path, confirm it is still inside the directory you meant, and only then open it. This article goes through the standard library joins, the Flask and Django file responses, upload names and zip and tar extraction, with code for each. The weakness is [CWE-22](https://cwe.mitre.org/data/definitions/22.html), and the 2025 OWASP Top 10 lists it under [A01 Broken Access Control](https://top10.owasp.org/2025/A01_2025-Broken_Access_Control/). #### os.path.join is a join, not a guard {#join} The Python documentation describes the behaviour exactly: "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" ([os.path.join](https://docs.python.org/3/library/os.path.html#os.path.join)). The documentation's own example is `os.path.join('/home/foo', '/home/bar')`, which returns `'/home/bar'`. So this handler has two separate problems: ```python import os DOCS = "/srv/app/user-docs" def read_document(name: str) -> bytes: with open(os.path.join(DOCS, name), "rb") as handle: return handle.read() ``` A `name` of `/etc/hostname` replaces the base entirely. A `name` of `../../../etc/hostname` keeps the base and climbs out of it, because `join` concatenates and nothing collapses the `..` segments until the operating system walks the path. `pathlib` reads more modern and behaves the same way. `Path("/srv/app/user-docs") / "/etc/hostname"` is `/etc/hostname`, and `..` segments are kept as written. #### Checks that look right and are not {#lookalikes} **Rejecting `..` in the string.** It does not cover the absolute-path case, and it runs before any decoding the application does later. A name that is percent-decoded, base64-decoded or pulled out of a JSON document after the check carries its traversal past it. **`os.path.normpath` alone.** It collapses `..` textually, so `normpath("/srv/app/user-docs/../../etc")` is `/srv/etc`. It tells you where the path points; it does not stop it pointing there. It also knows nothing about symbolic links. **A `startswith` test on strings.** This one survives code review regularly: ```python import os DOCS = "/srv/app/user-docs" def is_inside_wrong(path: str) -> bool: return os.path.realpath(path).startswith(DOCS) ``` `/srv/app/user-docs-private/secret.txt` starts with `/srv/app/user-docs`. A string prefix is not a directory boundary. #### The check that works {#check} Resolve first, then compare paths as paths: ```python from pathlib import Path DOCS = Path("/srv/app/user-docs").resolve() class OutsideBaseDirectory(Exception): pass def safe_path(name: str) -> Path: target = (DOCS / name).resolve() if not target.is_relative_to(DOCS): raise OutsideBaseDirectory(name) return target def read_document(name: str) -> bytes: target = safe_path(name) if not target.is_file(): raise FileNotFoundError(name) return target.read_bytes() ``` [`Path.resolve()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.resolve) makes the path absolute, collapses `..` and follows symbolic links. [`is_relative_to()`](https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.is_relative_to), available since Python 3.9, compares whole path components, so the `user-docs-private` case is rejected. With `os.path`, the equivalent is `os.path.commonpath([base, os.path.realpath(candidate)]) == base`. Two limits remain. If another user can create symbolic links inside the directory between your check and your open, the check can be raced; keep upload directories writable only by the service. And when the set of files is known, the sturdier design is to take no path at all: accept an identifier, look the real path up in a database row, and open that. #### Flask: send_file and send_from_directory {#flask} Flask's API reference is direct about `send_file`: "Never pass file paths provided by a user. The path is assumed to be trusted, so a user could craft a path to access a file you didn't intend. Use `send_from_directory()` to safely serve user-requested paths from within a directory" ([flask.send_file](https://flask.palletsprojects.com/en/stable/api/#flask.send_file)). ```python from flask import Flask, send_from_directory app = Flask(__name__) app.config["UPLOAD_FOLDER"] = "/srv/app/uploads" @app.get("/uploads/") def download(name: str): return send_from_directory(app.config["UPLOAD_FOLDER"], name, as_attachment=True) ``` [`send_from_directory`](https://flask.palletsprojects.com/en/stable/api/#flask.send_from_directory) joins with Werkzeug's [`safe_join`](https://werkzeug.palletsprojects.com/en/stable/utils/#werkzeug.security.safe_join) and answers 404 when the result would leave the directory. Note the argument order: the directory is the trusted argument and the path is the client's. A call that builds the directory from request data (a tenant name, a language code) hands the trusted slot back to the client. For names coming in, [`secure_filename`](https://werkzeug.palletsprojects.com/en/stable/utils/#werkzeug.utils.secure_filename) reduces an upload's filename to a flat, ASCII name. It can return an empty string, so check for that, and prefer a name you generate (a UUID) with the original kept as metadata. #### Django: FileResponse opens what you give it {#django} [`FileResponse`](https://docs.djangoproject.com/en/5.2/ref/request-response/#django.http.FileResponse) streams an open file object. It performs no path check, because by the time it is involved the file is already open: ```python from django.http import FileResponse, Http404 from .paths import OutsideBaseDirectory, safe_path def download(request, name: str): try: target = safe_path(name) except OutsideBaseDirectory: raise Http404("No such document") if not target.is_file(): raise Http404("No such document") return FileResponse(target.open("rb"), as_attachment=True) ``` Django's storage API and `FileField` validate names on the way in, which is one more reason to store uploads through them and serve by primary key, with the path read from the model. #### Archives bring their own paths {#archives} Extraction is traversal with the attacker writing the names. A member called `../../home/app/.ssh/authorized_keys`, or one with an absolute name, is written where the name says unless something stops it. **zipfile.** `ZipFile.extract()` and `extractall()` sanitise member names: the documentation notes that a leading slash or drive is stripped and "all `..` components in a member filename will be removed" ([ZipFile.extract](https://docs.python.org/3/library/zipfile.html#zipfile.ZipFile.extract)). The same page still warns, under `extractall()`, "Never extract archives from untrusted sources without prior inspection." The exposure usually comes from code that does not use those methods, because it wants to filter or rename members: ```python import os import shutil import zipfile def unpack_wrong(archive: str, dest: str) -> None: with zipfile.ZipFile(archive) as bundle: for name in bundle.namelist(): with bundle.open(name) as src, open(os.path.join(dest, name), "wb") as out: shutil.copyfileobj(src, out) ``` `name` goes straight into `os.path.join`, with both problems from the first section. Run every member through the same resolve-and-compare check: ```python import shutil import zipfile from pathlib import Path def unpack(archive: str, dest: str) -> None: base = Path(dest).resolve() with zipfile.ZipFile(archive) as bundle: for info in bundle.infolist(): target = (base / info.filename).resolve() if not target.is_relative_to(base): raise ValueError("member escapes destination: " + info.filename) if info.is_dir(): target.mkdir(parents=True, exist_ok=True) continue target.parent.mkdir(parents=True, exist_ok=True) with bundle.open(info) as src, open(target, "wb") as out: shutil.copyfileobj(src, out) ``` **tarfile.** Tar archives can also contain symbolic links, hard links and device files, so the names are only part of the problem. Python 3.12 added [extraction filters](https://docs.python.org/3/library/tarfile.html#extraction-filters); the `data` filter rejects absolute paths, links that point outside the destination and special files. The documentation records the change of default: in Python 3.14 the default extraction filter became `data`; before that, in the documentation's words, "the filter strategy was equivalent to `fully_trusted`". On 3.12 and 3.13, pass it yourself: ```python import tarfile def unpack_tar(archive: str, dest: str) -> None: with tarfile.open(archive) as bundle: bundle.extractall(dest, filter="data") ``` Neither module limits how much an archive expands to, so cap the total size and member count separately when the archive is untrusted. #### Second-order paths {#second-order} The hardest traversal to see in review is one where the name is stored first. A profile field, an "export name", a report template path is saved in one request with a parameterized `INSERT`, and joined into a path by a nightly job. Neither function looks wrong alone, and no request to the job carries a `..` at all. Apply `safe_path` at the point of use, every time, regardless of how trustworthy the stored value feels. #### What runtime evidence adds {#runtime} Review and tests cover the paths you thought of. es-python, the EyalSec Python runtime, adds what the running program actually did: it follows untrusted data through the program as it runs and checks it at the operation itself, the file call. When an untrusted path reaches `open()` or an `os` call, the event names the operation (`io open`, `os stat`, `os listdir`, `os unlink` and their siblings), and a `path traversal` event fires alongside when the untrusted path contains a `..` segment or is absolute. Archive extraction has its own labels, `zip slip` and `tar slip`, checked against the raw member name ([what the event looks like](https://eyalsec.com/vulnerabilities/path-traversal#event)). With the [db_rows source](https://eyalsec.com/docs/taint-sources#db-rows) on, a name read back from the database is followed too, which is how the second-order case appears. Know the limits. The matching is byte-level and POSIX-only: it looks for `/` separators and does not decode percent-encoding, so a value your application URL-decodes itself is covered only after that decode happens in Python. And an event is evidence of a flow, not a verdict: a plain `io open` on an untrusted name inside the intended directory is reported at a lower grade, because it may be exactly what the feature does. The event shows the value, where it entered and the call path so you can decide. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) the operation is stopped before the file is opened. See [How EyalSec works](https://eyalsec.com/docs/how-it-works) for the model and [pricing](https://eyalsec.com/pricing) for cost. #### Checklist {#checklist} - Never open `os.path.join(base, name)` or `base / name` without resolving and comparing. - Compare paths with `is_relative_to` or `commonpath`, never with `startswith`. - In Flask, `send_from_directory` with a fixed directory; in Django, check before `FileResponse`. - Generate stored filenames; keep the client's name as data. - Extract zips with the library methods or the per-member check; extract tars with `filter="data"`. - Re-check stored names at the point of use. If you want to see which file operations on your own services receive untrusted paths, you can [book a live demo](https://eyalsec.com/contact). ### ReDoS in Python: catastrophic backtracking in re and how to find it at runtime URL: https://eyalsec.com/research/redos-in-python-catastrophic-backtracking | Published: 2026-10-12 | Updated: 2026-10-12 | Author: EyalSec Research | Tags: python, redos, django Regular expression denial of service (ReDoS) in Python happens because the `re` module matches by backtracking: when a pattern can split the same text in many ways and the match finally does not succeed, the engine tries every split before giving up, and the work can double with each extra character. A short string sent to a validator can then hold a CPU core for seconds or minutes. The dependable fixes are to remove the ambiguity from the pattern, cap the input length, or use an engine that does not backtrack; and because the cost depends on the input as much as the pattern, the most reliable way to find it is to measure matches as they run. The weakness is [CWE-1333: Inefficient Regular Expression Complexity](https://cwe.mitre.org/data/definitions/1333.html). OWASP's [ReDoS page](https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS) is a good general introduction. #### One pattern, measured {#measured} The textbook example is a quantifier inside a quantifier over the same characters: ```python import re import time PATTERN = re.compile(r"^(a+)+$") for n in range(18, 25): text = "a" * n + "!" start = time.perf_counter() PATTERN.fullmatch(text) print(n, round(time.perf_counter() - start, 3)) ``` We ran this on Python 3.9 on a 2.5 GHz Xeon server. The output: | Characters of `a` | Seconds | |-------------------|---------| | 18 | 0.014 | | 19 | 0.039 | | 20 | 0.086 | | 21 | 0.136 | | 22 | 0.319 | | 23 | 0.738 | | 24 | 1.346 | Each extra character roughly doubles the time. At that rate a few dozen characters is minutes, and the input is still shorter than most email addresses. Your numbers will differ by machine and Python version; the doubling will not. Why it happens: `(a+)+` can divide a run of `a` characters into groups in exponentially many ways. While the match can succeed, the engine finds one way and stops. The trailing `!` guarantees no way succeeds, so the engine works through all of them. Two things are needed together: an ambiguous pattern, and an input that almost matches and then does not. #### The three shapes to look for {#shapes} Most vulnerable patterns in real code are one of these: 1. **Nested quantifiers**: `(a+)+`, `(\w+\s?)*`, `([a-z0-9]+)*`. The inner and outer repeat can both absorb the same character. 2. **Overlapping alternation under a repeat**: `(a|aa)+`, `(\d|\d\d)+`, `(.|\s)*`. Two branches match the same text. 3. **Adjacent repeats that overlap**: `\d+\d+`, `.*.*=`, `\s*\s*$`. This shape is polynomial, not exponential, so it takes a longer input to hurt, and it is common in hand-written parsers for headers and logs. The patterns do not look dangerous, and they often arrive from a search result for "email regex". They also live in dependencies. Django fixed [CVE-2023-36053](https://nvd.nist.gov/vuln/detail/CVE-2023-36053), a potential ReDoS in `EmailValidator` and `URLValidator` "via a very large number of domain name labels of emails and URLs", in its [3 July 2023 security release](https://www.djangoproject.com/weblog/2023/jul/03/security-releases/). NVD scores it 7.5 (High). Validators are the natural home for this bug: they run on untrusted input by definition, usually before authentication. #### When the client supplies the pattern {#pattern-source} A second form needs no flawed pattern in your code at all: ```python import re def search_notes(notes, user_pattern: str): matcher = re.compile(user_pattern) return [note for note in notes if matcher.search(note)] ``` A "search with regex" box hands the client the engine. They can supply `(a+)+$` themselves and choose the text to run it on. If the pattern is used as a filter (an allow rule, a routing rule), they can also write one that quietly accepts what the check was meant to reject. Compile only patterns you wrote. Where users need matching, offer literal substring search (`re.escape(user_text)` turns their input into a literal) or a small grammar of your own. #### Fixes that work {#fixes} **Remove the ambiguity.** Most nested quantifiers were never needed. `^(a+)+$` matches the same strings as `^a+$`, which runs in linear time. For the email-style case, `^[a-zA-Z0-9]+@example\.com$` replaces `^([a-zA-Z0-9]+)+@example\.com$`. **Cap the length before matching.** An exponential pattern on 12 characters is harmless. A length check is one line, runs in constant time, and protects patterns you have not audited: ```python import re USERNAME = re.compile(r"^[a-z0-9]+(?:[._-][a-z0-9]+)*$") MAX_USERNAME = 64 def valid_username(value: str) -> bool: return len(value) <= MAX_USERNAME and USERNAME.fullmatch(value) is not None ``` **Use possessive quantifiers or atomic groups on Python 3.11 and later.** Python 3.11 added atomic grouping `(?>...)` and possessive quantifiers such as `*+` and `++` ([What's New in Python 3.11](https://docs.python.org/3/whatsnew/3.11.html#re)). Both tell the engine not to give back what a group has matched, which removes the backtracking: `^(?>a+)+$` and `^(a++)+$` both reject the input above immediately. They change what the pattern matches in some cases, so re-run your tests. **Use an engine that does not backtrack.** [RE2](https://github.com/google/re2) guarantees matching time linear in the input size, and has Python bindings published as [`google-re2`](https://pypi.org/project/google-re2/). The trade is features: it has no backreferences and no lookaround. For user-supplied patterns it is the option that removes the risk by construction. **Know what `re` does not have.** The standard library's `re` has no timeout argument. The third-party [`regex`](https://pypi.org/project/regex/) module does: its matching functions accept `timeout=` in seconds. A timeout turns a hang into an exception, which is a useful backstop and not a substitute for fixing the pattern, since each timed-out request still burned its full allowance of CPU. #### Why review alone has a hard time {#review} Reading a pattern tells you whether it is ambiguous. It does not tell you whether an attacker's text reaches it, how long that text may be, or whether the pattern is anchored by code around it. Static pattern analysers are worth running, and they report candidates: patterns that could blow up on some input. Many candidates never see untrusted input, and some real cases are in patterns built at runtime or compiled inside a library. Fuzzing has the opposite limit. A ReDoS input returns the correct answer, slowly. Nothing crashes, so a coverage-guided fuzzer has no failure to report unless you add a time budget to the harness yourself. #### Finding it with a test {#test} You can add that time budget in a few lines. This helper pumps a string, times the match at two sizes, and reports the growth: ```python import re import time def growth_ratio(pattern: str, unit: str, suffix: str, small: int = 12, big: int = 18) -> float: compiled = re.compile(pattern) def timed(count: int) -> float: text = unit * count + suffix start = time.perf_counter() compiled.fullmatch(text) return max(time.perf_counter() - start, 1e-7) return timed(big) / timed(small) def test_username_pattern_is_not_exponential(): ratio = growth_ratio(r"^[a-z0-9]+(?:[._-][a-z0-9]+)*$", "a", "!") assert ratio < 20 ``` A linear pattern gives a ratio near the ratio of the sizes (here 1.5, plus noise). An exponential one gives a ratio in the dozens or more for six extra characters. Keep the sizes small so a vulnerable pattern fails the test quickly. The limit of this approach is that you choose the unit and the suffix: it confirms a suspicion about a pattern you already picked out. #### Finding it at runtime {#runtime} The complementary approach is to watch real matches. es-python, the EyalSec Python runtime, follows untrusted data through the program as it runs and checks it at the operation itself, which for this class is the match. It reports in two layers ([what the event looks like](https://eyalsec.com/vulnerabilities/redos#event)): - A base event (`re match`, `re search`, `re compile` and related labels) when untrusted data reaches the regex engine, as the text or as the pattern. This is the inventory: every place a client's string meets a regex, including patterns compiled inside dependencies. - A separate `redos` event only when a match actually backtracked heavily over untrusted input. es-python counts the engine's steps and reports when the count crosses both an absolute threshold and a steps-per-character threshold. The second threshold is what separates real super-linear backtracking from an ordinary scan of a large string. So a `redos` event says the work did blow up, on this pattern, with this input, at this call path. The pattern itself is shown in the event's **Regex** line ([event detail](https://eyalsec.com/docs/event-detail#detail)). Three things to know when reading these events. The `redos` impact tag always travels with a `regex bypass` tag, because one regex event can be either finding: a pattern that hangs, or a pattern used as a filter that an attacker loosened. Both are graded low, and you decide which the call site is; if the regex validates or filters, read it as a bypass first. Second, a pattern whose blow-up sits entirely inside a single bounded repeat is not counted, so the `redos` event covers the common nested-quantifier shape, not every possible one. Third, it reports what ran: a vulnerable pattern that no request or test exercised produces nothing, which is why this pairs well with [running your test suite](https://eyalsec.com/research/running-your-pytest-suite-under-the-eyalsec-python-runtime) under it. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) the match is stopped with an error. The model is on [How EyalSec works](https://eyalsec.com/docs/how-it-works) and cost on [pricing](https://eyalsec.com/pricing). #### Checklist {#checklist} - Search for nested quantifiers, overlapping alternations and adjacent overlapping repeats. - Cap input length before every regex that runs on client data. - Never compile a client-supplied pattern with `re`; escape it or use a linear-time engine. - On Python 3.11 and later, consider possessive quantifiers where backtracking is not needed. - Add a growth test for patterns on the request path. - Keep frameworks current: validator patterns get fixed upstream. If you want to see which regex calls on your own services receive untrusted input, and which ones blow up, you can [book a live demo](https://eyalsec.com/contact). ### Running your pytest suite under the EyalSec Python runtime: what it reports and how to read an event URL: https://eyalsec.com/research/running-your-pytest-suite-under-the-eyalsec-python-runtime | Published: 2026-10-12 | Updated: 2026-10-12 | Author: EyalSec Research | Tags: python, pytest, ci, sql-injection, command-injection To run a pytest suite under the EyalSec Python runtime you change one word: `es-python -m pytest` instead of `python -m pytest`. The tests run as before, and every time data from a source you switched on reaches a risky operation (a query, a command, a file path, a deserializer), the run reports an event with the value, where it entered and the call path. This tutorial walks through the commands, which sources make sense for a test suite, how to mark test input as untrusted, how to read each block of an event, and how to make CI act on the result. Your test suite already describes how your application is meant to be used. Running it under es-python reuses that work: each test becomes a small dataflow experiment. #### Before you start {#before} You need a machine with es-python installed and reporting to your dashboard. The [quick start](https://eyalsec.com/docs/quick-start) covers the account, the machine and the one-line installer; [Install es-python](https://eyalsec.com/docs/install) has the details. Confirm the install with the helper the installer adds: ```bash eyalsec selftest es-python -V es-python --name ``` `eyalsec selftest` sends a harmless test event and prints a link to it. `es-python --name` prints the account and machine this runtime reports to ([Running es-python](https://eyalsec.com/docs/running-es-python#which-machine)). es-python uses the packages installed for your regular Python of the same version, so install your project's dependencies and pytest the way you normally do. To keep things isolated, create the virtual environment with es-python itself: ```bash es-python -m venv .venv . .venv/bin/activate pip install -r requirements.txt pytest python -m pytest # inside this venv, "python" is es-python ``` #### Run the suite {#run} ```bash es-python -m pytest ``` Every pytest option works unchanged, because EyalSec options go before `-m` and everything after belongs to pytest ([es-python command line](https://eyalsec.com/docs/es-python-cli#flags)): ```bash es-python --report-code -m pytest tests/api -x -q # ^ EyalSec option ^ pytest and its own arguments ``` Nothing is reported unless at least one source is on. A source is a place where untrusted data enters the program. A machine you add starts with **socket** (network input) and **file** (files read) on; the rest are off until you switch them on in the machine's [Configure](https://eyalsec.com/docs/configure) window, or for one run with a flag or an environment variable. A dashboard **On** or **Off** always wins over a flag; the flag decides only while the control is unset. #### Choose sources that fit a test suite {#sources} Tests receive input differently from production, so the right sources differ too. The full list is on [Taint sources](https://eyalsec.com/docs/taint-sources); these are the ones that matter under pytest. | Source | Switch on for one run | Fits tests that | |--------|-----------------------|-----------------| | `socket` | `ES2_MAKE_SOCKET_VULN=1` | talk to a real listening server over a connection | | `file` | `ES2_MAKE_FILE_VULN=1` | read fixtures, uploads or sample documents from disk | | `stdin` | `ES2_MAKE_STDIN_VULN=1` | drive a command-line tool through standard input | | `env` | `ES2_MAKE_ENV_VULN=1` | pass input through environment variables or `sys.argv` | | `db_rows` | `ES2_MAKE_DB_ROWS_VULN=1` | exercise stored, second-order flows | | `make_vuln` | dashboard control only | mark a chosen value as untrusted in the test itself | Environment variables are the convenient form under pytest, because child processes inherit them: ```bash ES2_MAKE_FILE_VULN=1 ES2_MAKE_STDIN_VULN=1 es-python -m pytest ``` One point saves an afternoon of confusion. The **socket** source marks data returned by the socket receive calls. An in-process test client (Flask's `app.test_client()`, Django's `Client`, Starlette's `TestClient`) calls your application directly and reads no socket, so with only **socket** on, expect those tests to have nothing marked. If such a suite reports nothing, check this first. Two ways to give such a suite untrusted input follow. #### Mark test input as untrusted with make_vuln {#make-vuln} `make_vuln()` is a builtin that es-python adds: it marks the value you pass as untrusted and returns it. Set the **make_vuln** control to **On** for the machine; while it is unset or off, the call returns its argument unchanged. Under regular Python the name does not exist, so wrap it in a fixture that degrades to a no-op and the same tests run in both: ```python import builtins import pytest @pytest.fixture def untrusted(): """Return a function that marks a value as untrusted under es-python.""" return getattr(builtins, "make_vuln", lambda value: value) ``` A test then says which of its inputs an attacker would control: ```python def test_search_handles_hostile_term(client, untrusted): term = untrusted("report' OR '1'='1") response = client.get("/search", query_string={"q": term}) assert response.status_code == 200 ``` The assertion is the one you already had. What changes is that the term is now followed: if any code path concatenates it into SQL text, opens it as a path or hands it to a shell, the run reports that flow, whether or not the test's own assertion noticed. The second way is to test over a real connection: pytest-django's `live_server` fixture and similar helpers start a listening server, so the request bytes arrive through a socket and the **socket** source marks them. #### What the run reports {#reports} In Report mode the program is never interrupted, and pytest prints its usual summary. The findings are on the dashboard's **Events** page. Each row is one distinct finding, not one occurrence: when ten tests reach the same flow, one row shows a count of ten ([Events](https://eyalsec.com/docs/events#repeats)). On es-python the row reads as a sentence, with the technical form underneath: ``` Data from a network connection reached a system command socket → os system ``` The small line is the source (`socket`), an arrow, and the sink label (`os system`). Beside it are the impact tags, such as `sqli` or `command injection`, which name the class of vulnerability, and the severity, which EyalSec works out from how easily an attacker controls the source and how much damage the sink can do. To see only what this test run found, click **Runs** in the toolbar and pick the run: every start of a program under es-python is one run with its own id, and a pytest worker pool belongs to the same run as the command that started it ([Runs](https://eyalsec.com/docs/runs)). While the list is narrowed to one run, each row's count is that run's own occurrences. #### How to read an event {#read} Click a row to open it. The blocks appear in this order on an es-python event, and a block is shown only when the event has something to put in it ([Event detail](https://eyalsec.com/docs/event-detail)). | Block | Technical term | What it tells you | |-------|----------------|-------------------| | **What this means** | | Three parts: **What happened** (the Python call and the flow in one sentence), **Why this is risky**, and **What to do** | | The flow picture | | A **Source** circle, an arrow for the tainted value, and a **Sink** circle naming the call | | **Was it made safe?** | `sanitization` | **Yes**, **Only partly**, **No** or **Not checked**, with the exact reason recorded | | **Command line** | | How the program was started, with **Only this run** and **All runs of this command** buttons | | **Code where the data came in** / **Code where it was used** | `source code` / `sink code` | A short listing around each line, when the machine sends code | | **Value when the data came in** / **Value when it was used** | `repr created` / `repr found` | The untrusted value as text at both ends of the flow | | **Call path** | `stack trace` | The chain of calls running when the event happened, innermost last | | **Where it came from** | `origin` | The connection, or the file with its permissions and owner | A practical reading order for a test-suite event: 1. **What happened** names the call, for example `os.system()`. Search your code for it. 2. **Call path** shows which test reached it and through which functions. Under pytest the outer frames are pytest's own; the frames from your package are the ones to read. 3. Compare the two **Value** blocks. If the value at the sink is the test's input wrapped in SQL or shell syntax, the code built a statement from it. If the two are identical, the value was passed through untouched. 4. **Was it made safe?** tells you whether EyalSec could prove the value harmless at that operation. An event proven safe is [suppressed](https://eyalsec.com/docs/suppressed-events): stored, and out of the default view. The two code blocks need **Source code** switched on, with `--report-code`, `ES2_REPORT_CODE=1` or the dashboard setting. It is off by default because it sends lines of your source; for a test suite on your own code it is usually worth turning on, since each event then shows the line that received the data and the line that used it, with five lines either side ([Event context](https://eyalsec.com/docs/event-context#source-code)). An event is a real flow that happened on that test's input. It is not, by itself, proof of an exploitable bug: a fixture file your own repository wrote is a trusted origin, and the event's severity and the **Why this is risky** sentence say so. Read the origin before you read the severity. #### Decide what to do with each finding {#triage} Three outcomes cover almost every event from a test run: - **Fix the code.** The **What to do** line names the safe API: a bound parameter, an argument list in place of a shell string, a resolved path checked against its base directory. - **Record that the flow is expected.** A tool whose job is to run a command the operator typed will reach `os system` by design. Add a **Hide** [rule](https://eyalsec.com/docs/rules#mode-hide) for that sink on that machine, so the next run shows only what is new. - **Keep it as a regression signal.** Leave it visible until it is fixed. #### Gate a CI build on the run {#ci} For a pipeline, EyalSec provides a script that installs es-python on the runner with your API key, runs your command, waits for the events and exits non-zero when the run reported anything at or above a severity you choose. The GitHub Actions step, from [Run es-python in CI](https://eyalsec.com/docs/ci): ```yaml - name: Tests under es-python env: EYALSEC_API_KEY: ${{ secrets.EYALSEC_API_KEY }} run: | curl -fsSL https://eyalsec.com/ci.sh -o es-python-ci.sh sh es-python-ci.sh run --machine ci-${{ github.event.repository.name }} \ --distro ubuntu_24_04 --arch x86_64 \ -- es-python -m pytest ``` The script switches on **socket** and **file** for the command it runs; `--sources socket,file,env` chooses others. `--fail-on` sets the threshold (`high` is the default), and `--new-only` reports only findings no earlier run had reached, which is the gentlest way to adopt it on a codebase with history. Exit code `0` means nothing at or above the threshold, `3` means findings, `4` means the script could not do its job, and any other code is your own command's, which always wins. The summary names each finding's sink label and event number and never prints the value, because CI logs are often public. Give each pipeline its own machine name and run one job at a time per name ([caveats](https://eyalsec.com/docs/ci#caveats)). CI findings count toward your event quota; see [pricing](https://eyalsec.com/pricing). #### What to expect, honestly {#limits} - **Coverage is your tests' coverage.** A flow no test exercises produces no event. A run with no events means no watched data reached a risky operation on these inputs, nothing more. - **Sources cost time.** Each source adds work, and **file** on a suite that reads many files adds the most. Measure your own suite before switching everything on. - **Dataflow only.** A missing permission check or a missing CSRF token is not a flow of untrusted data into an operation, so it is outside what this reports. If you would like to see your own suite's first run read through with you, you can [book a live demo](https://eyalsec.com/contact). ### Securing a Django app against SQL injection: raw(), extra(), RawSQL, cursor.execute and the ORM edge cases URL: https://eyalsec.com/research/securing-a-django-app-against-sql-injection | Published: 2026-10-12 | Updated: 2026-10-12 | Author: EyalSec Research | Tags: python, django, sql-injection A Django application is safe from SQL injection as long as every value reaches the database as a bound parameter and every identifier (a column, a sort key, an alias, a keyword name) is chosen by your code rather than by the client. The four places where application code writes SQL text (`raw()`, `extra()`, `RawSQL` and `cursor.execute()`) break the first rule when they are fed formatted strings, and a handful of ORM methods break the second when a request field is passed through as a name. This guide covers each one with the rule from Django's own documentation, the review question to ask, a test that catches regressions, and what runtime evidence adds. #### The guarantee, and its exact edge {#guarantee} Django's security guide states the protection plainly: "a query's SQL code is defined separately from the query's parameters", and the parameters are escaped by the database driver ([SQL injection protection](https://docs.djangoproject.com/en/5.2/topics/security/#sql-injection-protection)). That guarantee covers values. `Entry.objects.filter(headline=term)` is safe for any `term`, because the text reaching the database is `... WHERE headline = %s` and the value travels separately. The edge of the guarantee is everything that is not a value. SQL has no way to bind a table name, a column name, an alias or a keyword, so those are written into the statement text. The guide says so in the same section: parameters cannot protect the SQL itself, and the escape hatches below hand the SQL to you. The whole of this guide is those two edges: text you write, and names the client picks. #### Escape hatch 1: cursor.execute() {#cursor-execute} [Executing custom SQL directly](https://docs.djangoproject.com/en/5.2/topics/db/sql/#executing-custom-sql-directly) is the lowest level. The documented form is `cursor.execute(sql, [params])`, with `%s` as the placeholder on every backend, including SQLite, where Django translates it for you. ```python from django.db import connection def totals_for(customer_id: int): with connection.cursor() as cursor: cursor.execute( "SELECT status, SUM(amount) FROM orders WHERE customer_id = %s GROUP BY status", [customer_id], ) return cursor.fetchall() ``` Two mistakes recur in review: 1. **Formatting the value in.** `cursor.execute(f"... WHERE customer_id = {customer_id}")` or `cursor.execute(sql % params)` runs the formatting in Python before the driver sees anything. An `int` annotation on the argument does not help: nothing enforces it at runtime unless a form or a URL converter did. 2. **Quoting the placeholder.** The docs are explicit: "you must not include quotes around the `%s` placeholders in the SQL string." `'%s'` turns a bound parameter into a string the database parses. #### Escape hatch 2: Manager.raw() {#raw} [`raw()`](https://docs.djangoproject.com/en/5.2/topics/db/sql/#performing-raw-queries) runs hand-written SQL and maps rows to model instances. It takes `params` as a list or a dictionary, and the dictionary form uses `%(name)s` placeholders. The documentation's warning is one line: "Do not use string formatting on raw queries or quote placeholders in your SQL strings!" ```python from myapp.models import Person def people_named(last_name: str): return list( Person.objects.raw( "SELECT id, first_name, last_name FROM myapp_person WHERE last_name = %(last)s", {"last": last_name}, ) ) ``` `raw()` does not check the SQL at all, so keep such queries in one module whose name signals review, not scattered across views. #### Escape hatch 3: QuerySet.extra() {#extra} [`extra()`](https://docs.djangoproject.com/en/5.2/ref/models/querysets/#extra) splices literal SQL into `SELECT`, `WHERE`, `ORDER BY` and table lists. Django's reference says it is "an old API that we aim to deprecate at some point in the future" and that the project is "no longer improving or fixing bugs for this method". That makes it the most likely place in an older codebase to find an f-string, because the code around it has not been touched in years. `extra()` accepts `params` for its `where` and `select_params` for its `select`, and those are bound correctly. The injection comes from the clauses themselves. `order_by` inside `extra()` has no parameters at all: it is pure text, so a sort key from the request lands straight in the SQL. ```python from myapp.models import Entry # Still common in old code: a formatted where clause def entries_with_tag_old(tag: str): return Entry.objects.extra(where=["tags LIKE %s"], params=[f"%{tag}%"]) # The ORM form, which binds the value and needs no extra() def entries_with_tag(tag: str): return Entry.objects.filter(tags__contains=tag) ``` The first function is correctly parameterized and still worth replacing: every `extra()` is a clause nobody is maintaining. Treat each remaining call as debt with an owner. #### Escape hatch 4: RawSQL expressions {#rawsql} [`RawSQL`](https://docs.djangoproject.com/en/5.2/ref/models/expressions/#raw-sql-expressions) embeds a SQL fragment inside an annotation, a filter or an ordering. Its `params` argument is required, and the reference explains why: "params is a required argument to force you to acknowledge that you're not interpolating your SQL with user-provided data." The same section warns, again, not to quote the placeholders. The anti-pattern is satisfying the signature with an empty tuple: ```python from django.db.models.expressions import RawSQL def ranked(queryset, tag: str): # Correct: the value is bound return queryset.annotate( score=RawSQL("SELECT score FROM ranking WHERE tag = %s", (tag,)) ) ``` When you review a `RawSQL`, read the first argument for any `{`, `%` formatting or `+`, and read the second for an empty tuple next to a non-constant first argument. Either is a finding. The expressions reference has one more trap in the same family. A custom [`Func`](https://docs.djangoproject.com/en/5.2/ref/models/expressions/#func-expressions) has a `template` and accepts `**extra` keyword arguments that are interpolated into that template rather than bound. The docs say `extra` "must not contain untrusted user input", and the same holds for `arg_joiner` and `template` themselves. A `Func` subclass that takes a request value as a template keyword is `RawSQL` with better manners. #### The ORM edge cases: names the client picks {#names} The second edge is subtler, because there is no SQL in the application at all. The ORM must write identifiers into the statement, and some of its methods take identifiers from you as strings or keyword names. When a request field is passed through as one of those, the client is choosing SQL text. **Sort keys.** `order_by()` resolves field names against the model and rejects unknown ones, which is good, but it has not always been complete: [CVE-2021-35042](https://nvd.nist.gov/vuln/detail/CVE-2021-35042) was SQL injection in `QuerySet.order_by()` "if order_by is untrusted input from a client of a web application". Allowlist the sort key in your own code regardless of the Django version: ```python SORTS = {"newest": "-created_at", "oldest": "created_at", "title": "title"} def sorted_entries(queryset, sort: str): return queryset.order_by(SORTS.get(sort, "-created_at")) ``` **Column aliases.** The two severe Django SQL injection CVEs of 2024 and 2025 were both alias injection: [CVE-2024-42005](https://nvd.nist.gov/vuln/detail/CVE-2024-42005) (CVSS 7.3 High per NVD; CISA rates it 9.8 Critical) through a JSON key passed to `values()` or `values_list()`, and [CVE-2025-57833](https://nvd.nist.gov/vuln/detail/CVE-2025-57833) (CVSS 8.1 High per NVD) through `FilteredRelation` names expanded into `annotate()` or `alias()`. Both advisories credit Eyal Gabay of EyalSec; Django's security team fixed both promptly, and this guide owes them thanks. The shape to look for in your own code is a request value used as a projection key or a keyword name, and [the full account](https://eyalsec.com/research/django-column-alias-sql-injection-cve-2024-42005-cve-2025-57833) covers why one validator was missing on those paths. **Keyword expansion.** [CVE-2025-64459](https://www.djangoproject.com/weblog/2025/nov/05/security-releases/) extended the same family to `filter()`, `exclude()`, `get()` and `Q()` through a crafted `_connector` key in a dictionary expanded with `**`. The code pattern all of these reward is the same one: ```python ALLOWED_FILTERS = {"category", "brand", "in_stock"} def filtered_products(queryset, params: dict): # The code chooses the names; the client supplies only values filters = {k: v for k, v in params.items() if k in ALLOWED_FILTERS} return queryset.filter(**filters) ``` `Product.objects.filter(**request.GET.dict())` is the one-liner to search for, together with `annotate(**`, `values(*` and `alias(**` fed from a request, a JSON body or a form's `cleaned_data` keys. Keep Django current for the framework's own fixes, and keep the allowlist for the shape itself, because the class has recurred several times. #### A test that catches both edges {#test} Code review is the primary control. A regression test is the backstop, and Django gives you the tool: `CaptureQueriesContext` records every statement the test executed, with its final SQL text. If a marker string you passed as input ever appears in that text, a value became SQL. ```python import pytest from django.db import connection from django.test.utils import CaptureQueriesContext MARKER = "zqx_marker_7f3e" @pytest.mark.django_db def test_search_binds_the_term(client): with CaptureQueriesContext(connection) as ctx: client.get("/search/", {"q": MARKER, "sort": MARKER}) for query in ctx.captured_queries: assert MARKER not in query["sql"], query["sql"] ``` On most backends the recorded `sql` is the statement with parameters substituted for display, so a bound value does appear in it. Run the test against a marker that contains a quote character, `zqx'marker`, and assert on the unescaped form: a bound parameter shows up escaped, a formatted one shows up raw. For sort keys and filter names the assertion is simpler, because a legitimate identifier never contains your marker at all, so any appearance is a finding. Add one such test per view that accepts a search term, a sort key or a filter dictionary. It costs seconds and it catches the f-string someone adds to a `raw()` call two years from now. #### The second-order case {#second-order} Everything above assumes the untrusted value arrives in the request that uses it. The harder case is a value stored safely in one request and read back into SQL text in another: a saved "report column" preference, a tag name used later as an `extra(order_by=...)` key, a tenant's custom field name. The write is parameterized and correct. The read returns a plain string, and the string goes into a query as a name. The review rule is the same (a stored value is still a client value), but review finds it less often, because the two halves live in different files. This is the case where a runtime view earns its keep. #### What runtime evidence adds {#runtime} Review, Bandit's [B608](https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html) and a capture test are each worth running. What they share is that they reason about code or about one test's input. es-python, the EyalSec Python runtime, adds the running system's own answer: it follows untrusted data through the program as it runs and checks it at the operation itself, the moment a statement is sent, so it reports a query only when attacker-controlled characters were actually part of the SQL text. A correctly bound parameter never enters that text, so a correctly parameterized query produces no event; a value that became an identifier does. The event is labelled `sql raw` for `raw()` and `extra()` and by the driver's own call for `cursor.execute()`, carries the `sqli` impact tag, and shows the value, where it entered and the call path ([what the event looks like](https://eyalsec.com/vulnerabilities/sql-injection#event)). With the [db_rows source](https://eyalsec.com/docs/taint-sources#db-rows) on, values read back from the database are followed too, which is how the second-order case above shows up with the stored string named as the source. An event is evidence that the flow happened, not proof that it is exploitable; if the code compared the value against a fixed set first, the flow is real and harmless, and the event gives you what you need to say so. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) the statement is stopped before it reaches the database. Pricing is metered per machine and per event; see [pricing](https://eyalsec.com/pricing). #### Checklist {#checklist} | Where | Rule | Review question | |-------|------|-----------------| | `cursor.execute()` | `%s` placeholders, values in the second argument, never quoted | Is the first argument a constant? | | `raw()` | `params=` list or dict, no formatting | Same, plus: is the statement read-only? | | `extra()` | `params=` and `select_params=`; `order_by` is pure text | Can this be an ORM expression instead? | | `RawSQL`, `Func` | `params` holds every value; `template` and `extra` hold no input | Is `params` an empty tuple next to a non-constant? | | `order_by()`, `values()`, `annotate(**)`, `filter(**)` | names from an allowlist your code owns | Does any name trace back to a request or a stored string? | If you want to see which of your Django queries actually receive untrusted data in their text, on your own code, you can [book a live demo](https://eyalsec.com/contact). ### Server-side template injection in Jinja2 and Django templates: where user input meets the renderer URL: https://eyalsec.com/research/server-side-template-injection-in-jinja2-and-django-templates | Published: 2026-10-12 | Updated: 2026-10-12 | Author: EyalSec Research | Tags: python, server-side-template-injection, code-injection, flask, django Server-side template injection (SSTI) happens when user input becomes part of a template's source instead of a value in its context, so the template engine evaluates what the user wrote. In Jinja2 that can lead to code running on the server; in Django's more restricted template language it exposes whatever objects the context carries. The rule that prevents both is one line long: template text is code, so it comes from your repository, and user input goes in the context. This article shows where the two meet by accident, what each engine allows once they do, and what to do when users really do need to write templates. SSTI is classified as [CWE-1336](https://cwe.mitre.org/data/definitions/1336.html) and belongs to OWASP's [A05:2025 Injection](https://top10.owasp.org/2025/A05_2025-Injection/). The class was mapped out in public by James Kettle's 2015 research, [Server-Side Template Injection](https://portswigger.net/research/server-side-template-injection), which remains the reference, and the OWASP testing guide has a [procedure for it](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/18-Testing_for_Server-side_Template_Injection). #### Template and context are different things {#line} Every engine has two inputs. The template is a small program: text with expressions, loops and conditions. The context is data: the values those expressions read. The engine compiles the first and substitutes the second. ```python from flask import Flask, render_template_string, request app = Flask(__name__) @app.get("/hello") def hello(): name = request.args.get("name", "there") return render_template_string("

Hello {{ name }}

", name=name) ``` Here the template is a constant and `name` is context. Whatever the visitor sends, including `{{ 7*7 }}`, is rendered as literal text and HTML-escaped. This is the correct form. SSTI is the same handler with the value moved one argument to the left: ```python @app.get("/hello-wrong") def hello_wrong(): name = request.args.get("name", "there") return render_template_string(f"

Hello {name}

") ``` Now the f-string builds the template, so the visitor's text is compiled. A `name` of `{{ 7*7 }}` renders `49`, which is the standard, harmless way the flaw is confirmed. Autoescaping does not help here. It protects the page from HTML in a context value, which is cross-site scripting. It has no bearing on what the engine evaluates. #### Where the two meet by accident {#where} Few developers set out to render user input as a template. It arrives through features that feel like text handling: - **A greeting or subject line built with an f-string or `+`**, then passed to `render_template_string`, `jinja2.Template` or `Environment.from_string`. Flask's own reference describes [`render_template_string`](https://flask.palletsprojects.com/en/stable/api/#flask.render_template_string) as rendering a template from a source string, and Jinja's [`from_string`](https://jinja.palletsprojects.com/en/stable/api/#jinja2.Environment.from_string) says it loads a template from a source string. Both treat their first argument as code. - **Templates stored in the database.** Editable notification emails, page fragments, report headers. The request that saves the text and the job that renders it are in different places. - **Two-pass rendering.** A template is rendered once to produce another template (for example to fill in a tenant's branding), and the result is rendered again with the user's data. Any user value included in the first pass becomes code in the second. - **Error and "not found" pages** that include the requested path in a message that is then rendered as a template. - **Format strings.** `user_text.format(obj)` is not a template engine, and it has the same shape: a format string can read attributes of its arguments (`{0.attribute}`), so a user-supplied format string can read whatever the objects passed to it expose. Use `string.Template` for user text. #### What Jinja2 allows {#jinja2} Jinja2 expressions can read attributes, index into objects and call methods. That is what makes the engine pleasant to use, and it is why a user-written expression is serious: starting from any object in scope, an expression can walk Python's object model to reach classes and functions the template was never given. The public research linked above documents the path in detail. For a defender, the conclusion is enough: in an unsandboxed Jinja2 environment, user-controlled template source should be treated as the ability to run code as the application. Mako and Tornado templates go further by design, since their expressions are plain Python. #### What Django templates allow {#django} Django's template language is deliberately smaller. It cannot call a function with arguments and cannot evaluate arbitrary Python. The documentation states that variable names "may not start with an underscore" ([template language](https://docs.djangoproject.com/en/5.2/ref/templates/language/)), which closes the attribute walk Jinja2 payloads rely on, and that the engine will not call a callable that has `alters_data = True`, which model `delete()` and `save()` methods get automatically ([template API](https://docs.djangoproject.com/en/5.2/ref/templates/api/#variables-and-lookups)). That is a real reduction in risk, and it is not the same as safe. A user-written Django template can still: - read every attribute and call every argument-less method of every object in the context, including ones you did not mean to show (`{{ user.email }}`, `{{ order.customer.address }}`, anything reachable by following relations); - call argument-less methods that change state and were not marked `alters_data`; - use any loaded tag or filter, including `{% include %}` to pull in other templates, and `{% debug %}` to dump the context when `DEBUG` is on; - consume CPU and memory with nested loops. So for Django the question to ask about a user-authored template is "what is in the context, and what can be reached from it?" ```python from django.template import Context, Template def render_subject_wrong(user_text: str, order) -> str: # user_text is compiled: it can read anything reachable from `order` return Template(user_text).render(Context({"order": order})) def render_subject(order) -> str: # the template is ours; the order number is a value return Template("Your order {{ number }} has shipped").render( Context({"number": order.number}) ) ``` #### When users must write templates {#user-templates} Some products need this: customers editing their own notification emails, for example. In order of preference: **1. A substitution-only format.** If users only need to place values, `string.Template` does that and nothing else: `$name` placeholders, no attribute access, no expressions. ```python from string import Template def render_notification(user_template: str, values: dict) -> str: return Template(user_template).safe_substitute(values) ``` **2. A sandboxed engine with a plain-data context.** When users need loops and conditions, Jinja provides [`SandboxedEnvironment`](https://jinja.palletsprojects.com/en/stable/sandbox/), which intercepts access to attributes, method calls, operators and string formatting. Pass dictionaries, lists, strings and numbers, never model instances or request objects. ```python from jinja2.sandbox import SandboxedEnvironment _env = SandboxedEnvironment(autoescape=True) def render_user_template(source: str, data: dict) -> str: return _env.from_string(source).render(**data) ``` Treat the sandbox as a reduction of risk that needs upkeep. The Jinja project has shipped sandbox fixes more than once, including CVE-2024-56326, CVE-2024-56201 and CVE-2025-27516 (listed in the [OSV database](https://osv.dev/list?q=jinja2&ecosystem=PyPI)), and the maintainers fixed each. Keep Jinja2 current, render user templates with a time limit, and cap output size. **3. Render somewhere that has nothing to lose.** For full template power, render in a separate process or container with no credentials, no network and no application objects, and pass the result back as text. #### How to find it in a codebase {#finding} Search for the calls that compile text: `render_template_string(`, `Template(`, `from_string(`, `Engine.from_string`, `mako.template.Template(`, `tornado.template.Template(`. For each, ask one question: is the first argument a constant from your repository? An f-string, a concatenation, a `.format()` call or a value loaded from the database in that position is a finding to trace. Linters and static analysis rules do this well when the non-constant argument is visible at the call. The harder cases are the ones from the list above: template text saved in one request and rendered by a job, a fragment assembled across three helpers, a render whose output goes into an email so no response ever shows the result. In those, the value's origin and the render call are far apart. #### What runtime evidence adds {#runtime} es-python, the EyalSec Python runtime, follows untrusted data through the program as it runs and checks it at the operation itself: here, the call that turns text into a template. Untrusted data in the template source is reported as `ssti jinja2` (for `Environment.from_string`, which `jinja2.Template` and Flask's `render_template_string` both use), `ssti django`, `ssti mako` or `ssti tornado`, each carrying the `ssti` impact tag. Because the check is on the template text, the correct form, a constant template with untrusted values in its context, produces no SSTI event ([what the event looks like](https://eyalsec.com/vulnerabilities/server-side-template-injection#event)). Three details help when reading these events: - The event fires whether or not the input contained template syntax, because the flaw is the flow, not one particular value. When EyalSec can prove the untrusted text carried nothing the engine would read as syntax, the event is [suppressed](https://eyalsec.com/docs/suppressed-events): stored, and out of the default view. - A user-authored template passed to a `SandboxedEnvironment` is still reported, since the user is still writing template code. If that is a product feature, record the decision with a [rule](https://eyalsec.com/docs/rules) for that call site. - With the [db_rows source](https://eyalsec.com/docs/taint-sources#db-rows) on, template text read back from the database is followed, so a stored template rendered by a background job is reported with the stored value as its source. An event is evidence that untrusted text reached the compiler, with the value, where it entered and the call path. It is for you to judge what that template could reach. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) the program gets an error at that call instead of a rendered page. The model is described on [How EyalSec works](https://eyalsec.com/docs/how-it-works), and cost on [pricing](https://eyalsec.com/pricing). #### Summary {#summary} | Situation | Do this | |-----------|---------| | Any render call | first argument is a constant; user input goes in the context | | Users place values in text | `string.Template(...).safe_substitute(...)` | | Users need logic | `SandboxedEnvironment`, plain-data context, current Jinja2, time and size limits | | Django user templates | minimal context of plain values; no model instances | | Stored templates | treat the stored text as user input at the point of rendering | If you want to see which render calls on your own services receive untrusted text, you can [book a live demo](https://eyalsec.com/contact). ### SSRF in Python services: requests, httpx, urllib and the cloud metadata endpoint URL: https://eyalsec.com/research/ssrf-in-python-services | Published: 2026-10-12 | Updated: 2026-10-12 | Author: EyalSec Research | Tags: python, ssrf, flask, fastapi Server-side request forgery (SSRF) happens when a Python service fetches a URL that a client chose, so the client borrows the server's network position: it can reach `localhost`, private addresses and the cloud metadata endpoint at `169.254.169.254`, which hands out credentials. The defence that holds is to decide on the address the connection will actually dial, on every hop, and to back that with network controls, because checks on the URL string are easy to get around. This article shows how the three common HTTP clients differ, what the metadata endpoint changes, the bypasses that matter, and code you can adapt. SSRF is [CWE-918](https://cwe.mitre.org/data/definitions/918.html). The 2025 OWASP Top 10 folds it into [A01 Broken Access Control](https://top10.owasp.org/2025/A01_2025-Broken_Access_Control/), which fits: the attacker gains access they were never granted, through a request your server makes for them. #### Where it starts {#where} Any feature that fetches a client-supplied address is a candidate: a webhook target, "import from URL", a link preview, an avatar URL, a PDF renderer that loads remote images, an OpenID or SAML metadata fetch. The handler is usually three lines: ```python import requests from flask import Flask, request app = Flask(__name__) @app.post("/preview") def preview(): url = request.form["url"] response = requests.get(url, timeout=5) return {"length": len(response.text)} ``` Nothing here looks wrong, and against public addresses nothing is. The flaw is that the server will just as readily fetch `http://127.0.0.1:6379/`, `http://10.0.0.12/admin` or the metadata endpoint, none of which the client could reach directly. #### How the three clients differ {#clients} The clients behave differently in ways that matter for SSRF, and each project documents its behaviour clearly. | Client | Redirects by default | Schemes | Note | |--------|----------------------|---------|------| | `requests` | followed | `http`, `https` | "By default Requests will perform location redirection for all verbs except HEAD" ([quickstart](https://requests.readthedocs.io/en/latest/user/quickstart/#redirection-and-history)) | | `httpx` | not followed | `http`, `https` | "Unlike requests, HTTPX does not follow redirects by default" ([compatibility guide](https://www.python-httpx.org/compatibility/)) | | `urllib.request.urlopen` | followed | `http`, `https`, `ftp`, `file`, `data` | the default opener includes handlers for local files ([urllib.request](https://docs.python.org/3/library/urllib.request.html#urllib.request.urlopen)) | Two consequences follow. First, with `requests` and `urllib`, validating the URL you were given is not validating the URL that gets fetched: a public host can answer with a `302` to an internal one, and the client follows it. Second, `urlopen` accepts `file:` URLs, so a client-supplied URL passed to it is a local file read as well as a network request. Bandit reports this as [B310](https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b310-urllib-urlopen). Restrict the scheme before anything else. `httpx`'s default is the safer one, and it is easy to undo: `follow_redirects=True` on the client restores the `requests` behaviour for every call made with it. #### Why the metadata endpoint raises the stakes {#metadata} On the major clouds a virtual machine can ask a link-local HTTP service about itself, and that service returns, among other things, temporary credentials for the role attached to the machine. All three use the same address: - **AWS**: `http://169.254.169.254/latest/meta-data/`. IMDSv2 requires a session token obtained with a `PUT` request and sent back in a header, and AWS lets you require it ([instance metadata options](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html)). - **Google Cloud**: `http://metadata.google.internal/computeMetadata/v1/`, which requires the header `Metadata-Flavor: Google` ([querying metadata](https://cloud.google.com/compute/docs/metadata/querying-metadata)). - **Azure**: `http://169.254.169.254/metadata/instance`, which requires the header `Metadata: true` ([Instance Metadata Service](https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service)). Those header and method requirements exist because of SSRF: a plain `GET` to an attacker-chosen URL carries no custom header and cannot send a `PUT`, so the simplest form of the attack gets nothing back. They are a real improvement and worth enforcing. They do not cover a handler that lets the client set headers or the method (a "test this webhook" form often does both), and they do nothing for the other internal targets: an unauthenticated Redis, an admin port bound to `localhost`, a neighbouring service that trusts the private network. #### The bypasses that matter {#bypasses} Filters on the URL string are attractive because they are short. These are the reasons they are not enough: - **Redirects.** Covered above: the first URL passes, the second is internal. - **DNS.** `internal.example.com` is a perfectly public-looking name that can resolve to `10.0.0.5`. Any name the attacker controls can resolve to any address. - **Alternate spellings.** The system resolver accepts more than dotted quads. On Linux, `socket.getaddrinfo("2852039166", 80)` returns `169.254.169.254` and `0x7f.1` resolves to `127.0.0.1`, so a deny-list of strings does not hold. - **IPv6 forms.** `::ffff:169.254.169.254` is the same destination written as an IPv4-mapped IPv6 address. Unwrap `ipv4_mapped` before testing. - **Resolve twice.** If your check resolves the name and the HTTP client resolves it again, an attacker's DNS server can answer the first lookup with a public address and the second with an internal one (DNS rebinding). - **URL parser differences.** Two parsers can disagree on which part of `http://a@b` or a URL with unusual characters is the host. Take the host from the same parser the client uses, or from the connection itself. #### A check that holds up {#check} Decide on the resolved address, not the string, and do it for every hop. The standard library's [`ipaddress`](https://docs.python.org/3/library/ipaddress.html#ipaddress.IPv4Address.is_private) module carries the classification, so there is no need to maintain your own list of ranges: ```python import ipaddress import socket from urllib.parse import urljoin, urlparse import requests ALLOWED_SCHEMES = {"http", "https"} MAX_HOPS = 3 class ForbiddenDestination(Exception): pass def _check_address(text: str) -> None: address = ipaddress.ip_address(text) mapped = getattr(address, "ipv4_mapped", None) if mapped is not None: address = mapped if not address.is_global or address.is_multicast: raise ForbiddenDestination(text) def check_url(url: str) -> None: parts = urlparse(url) if parts.scheme not in ALLOWED_SCHEMES or not parts.hostname: raise ForbiddenDestination(url) port = parts.port or (443 if parts.scheme == "https" else 80) for info in socket.getaddrinfo(parts.hostname, port, proto=socket.IPPROTO_TCP): _check_address(info[4][0]) def fetch(url: str) -> requests.Response: for _ in range(MAX_HOPS): check_url(url) response = requests.get(url, timeout=5, allow_redirects=False) if not response.is_redirect: return response url = urljoin(url, response.headers["Location"]) raise ForbiddenDestination("too many redirects") ``` `is_global` is the allow-shaped test: it is false for private, loopback, link-local, reserved and shared address space, so new special ranges are excluded by default. Redirects are disabled on the client and followed by hand, with the check repeated on each hop. This code still resolves the name twice, once in `check_url` and once inside `requests`, so it narrows the rebinding window without closing it. Closing it means dialling the address you checked. In `requests` that is a custom [transport adapter](https://requests.readthedocs.io/en/latest/user/advanced/#transport-adapters); in `httpx` it is a custom [transport](https://www.python-httpx.org/advanced/transports/). Both are more code than most teams want to own, which leads to the layer that does not depend on application code at all. #### Controls outside the code {#network} 1. **Send outbound fetches through an egress proxy with an allowlist.** One policy, enforced in one place, for every call site and every library, including the ones you have not found yet. 2. **Require the token-based metadata service.** On AWS, require IMDSv2 and keep the response hop limit at its minimum so a container behind an extra network hop does not receive the token. Apply the equivalent settings on the other clouds. 3. **Give the machine's role only what the service needs.** If a credential does leak, its permissions are the blast radius. 4. **Put the fetcher in its own network segment** when fetching arbitrary URLs is the product (crawlers, preview services), so "internal" contains nothing of value. #### Finding SSRF in an existing service {#finding} Search for the call sites: `requests.`, `httpx.`, `urlopen(`, `aiohttp`, `urllib3`, `pycurl`, and libraries that fetch on your behalf (PDF and image renderers, XML parsers with external entities, feed readers). For each, trace the URL back. Static search does this part well when the request value reaches the client in the same function. It has a harder time when the URL is stored first (a webhook saved today and called by a worker tonight), built from pieces, or passed into a third-party library that makes the request several calls down. That is the part a runtime view adds. es-python, the EyalSec Python runtime, follows untrusted data through the program as it runs and checks it at the operation itself: the point where the connection is made. Whichever client library made the call, an untrusted destination produces a `socket connect`, `socket getaddrinfo` or `socket gethostbyname` event, and when the address being dialled is loopback, link-local or in a private range, an `ssrf` event fires alongside it. Because the decision is made on the address the program is dialling, an in-application redirect to an internal address is reported too ([what the event looks like](https://eyalsec.com/vulnerabilities/ssrf#event)). The limits are worth knowing. The address test recognises the standard dotted-quad and canonical IPv6 spellings, not alternate encodings of the same address; a name that resolves internally is caught only when the program resolves it explicitly through Python; and there is no DNS-rebinding coverage. So treat the `ssrf` label as a strong signal when it fires, and the base `socket connect` event on untrusted input as the list of every outbound destination a request steered. An event shows that the flow happened, with the value, its source and the call path; whether the destination was reachable and useful to an attacker is your judgement. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) the connection is stopped before it is made. The model behind this is on [How EyalSec works](https://eyalsec.com/docs/how-it-works), and costs are on [pricing](https://eyalsec.com/pricing). #### Summary {#summary} | Layer | Do this | |-------|---------| | Scheme | allow `http` and `https` only; never pass a client URL to `urlopen` unchecked | | Destination | resolve, unwrap IPv4-mapped addresses, require `is_global` | | Redirects | disable automatic following; re-check every hop | | Metadata | require the token or header-based service; minimal role permissions | | Network | egress proxy with an allowlist; isolate fetchers | | Evidence | watch which outbound connections untrusted input actually steers | If you want to see which outbound connections on your own services are steered by untrusted input, you can [book a live demo](https://eyalsec.com/contact). ### What a RASP, an IAST and a WAF each see of the same attack: a comparison by category URL: https://eyalsec.com/research/what-a-rasp-an-iast-and-a-waf-each-see-of-the-same-attack | Published: 2026-10-12 | Updated: 2026-10-12 | Author: EyalSec Research | Tags: python, waf, rasp, iast, sql-injection, command-injection A WAF sees the HTTP request before the application does. An IAST sensor sees data moving through the application code it instruments while tests or traffic exercise it. A RASP agent sees the application's calls from inside the process and can stop one. Each is a different vantage point on the same attack, and each answers a different question well; what they see depends on where they stand and on what they are scoped to follow. This article walks one SQL injection through three delivery routes and describes, by category and with no product named, what each kind of tool observes, the questions worth asking about the one you run, and what a view at the operation itself adds next to them. If you already run one or more of these, this is meant to help you get more from them, and to reason about what sits between them. #### The three categories, in their own terms {#definitions} **WAF.** OWASP defines a web application firewall as "an application firewall for HTTP applications" that "applies a set of rules to an HTTP conversation" ([OWASP: Web Application Firewall](https://owasp.org/www-community/Web_Application_Firewall)). It sits in front of the application, needs no change to it, and decides on the request as received. **IAST.** Interactive application security testing tests the application while an automated test, a human tester or other activity exercises it, and "the core of an IAST tool is sensor modules, software libraries included in the application code" ([OWASP DevSecOps Guideline: IAST](https://owasp.org/www-project-devsecops-guideline/latest/02c-Interactive-Application-Security-Testing)). It is primarily a testing-phase tool: it reports vulnerabilities with code-level context. **RASP.** Runtime application self-protection is recognised as a control in NIST SP 800-53 Rev. 5, enhancement SI-7(17), which describes it as using runtime instrumentation to detect and block the exploitation of software vulnerabilities ([NIST SP 800-53 Rev. 5](https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final)). It runs inside the production process and can stop a call. All three are established, useful controls. Compliance frameworks name them, and teams that run them catch real attacks with them. #### One attack, three routes {#attack} Take a single flaw: a report endpoint that builds SQL by concatenation. ```python import base64 import sqlite3 def find_items(conn: sqlite3.Connection, name: str): # the flaw: the value becomes part of the SQL text return conn.execute("SELECT id FROM item WHERE name = '" + name + "'").fetchall() def route_plain(conn, params: dict): return find_items(conn, params["q"]) def route_decoded(conn, params: dict): # the client sends base64; the SQL metacharacters appear only after this line return find_items(conn, base64.b64decode(params["q"]).decode()) def route_worker(conn, message: dict): # no HTTP request: a job pulled from a queue by a background worker return find_items(conn, message["item_name"]) ``` The fix is the same for all three (`conn.execute("... WHERE name = ?", (name,))`), and nothing below is a reason to delay it. The question here is what each vantage point observes while the flaw exists. - **Route A, plain.** The injection string is in the query parameter, as typed. - **Route B, decoded in the application.** The parameter is a harmless-looking base64 string. The injection exists only after `b64decode`. - **Route C, off HTTP.** The value was submitted earlier, stored or queued, and reaches the query in a worker process that never handles a web request. #### What a WAF sees {#waf} On Route A, the WAF is at its best. A managed SQL injection rule set recognises the shape in the parameter and blocks the request before the application runs a line of code. That is a great deal of value for no application change, and it also absorbs the constant background of automated scanning. On Route B, the WAF sees the request exactly as sent: a base64 string, which matches no injection signature. Some WAFs decode common encodings before matching, which extends their reach to those encodings; an application-specific transform (a custom token format, a replace step, a field assembled from two parameters) is by nature known only to the application. The WAF's view ends at the application's front door, and the dangerous string is made behind it. On Route C, there is no request for the WAF to read at the moment of danger. The original submission may have passed through it earlier as ordinary data. **Questions for your WAF:** which encodings does it decode before matching? Does it inspect request bodies in the formats your API uses, and up to what size? Which internal, service-to-service paths never cross it? #### What an IAST sensor sees {#iast} IAST stands inside the application during testing, which gives it something the WAF does not have: the code path. When a test drives Route A or Route B, an IAST sensor that follows the value through `b64decode` to the query reports the vulnerability with the file and line, and the developer gets a finding they can fix. For Route B that is exactly the visibility the edge lacks. Its view is shaped by two things. First, it reports what the tests exercise: Route C is found if a test runs the worker with a hostile job, and remains unexamined if not. Second, it follows data through the code its sensors instrument. Products in this category document where propagation ends, and common boundaries are compiled extension modules and parsers written by hand in the application. A value that passes through such a boundary can arrive at the query without its marking. **Questions for your IAST:** which frameworks and libraries does it instrument in Python? What does its documentation say about data passing through compiled extensions? Do your test suites run the workers and scheduled jobs, or only the web tier? #### What a RASP agent sees {#rasp} RASP stands inside the production process and can act. On Routes A and B, an agent that hooks the database call sees the final query, after any decoding, and can block it. Seeing the assembled statement is the important property: encoding at the edge no longer matters. The scope questions are different again. Many agents in this category are designed around the HTTP request lifecycle: they inspect the incoming request, then watch the queries and file operations that result from it. That design keeps overhead and noise down for web traffic. For Route C it means the worker's query has no request to be tied to. Some products in the category focus on a different job, matching loaded libraries against known vulnerabilities, which answers "are we running a component with a published CVE?" and is not aimed at a flaw in first-party code such as `find_items`. **Questions for your RASP:** does it cover processes that serve no HTTP (workers, consumers, cron jobs)? Does it decide on the query alone, or does it know which part of the query came from the request? Which Python database drivers does it hook? #### Side by side {#table} | | WAF | IAST | RASP | |---|-----|------|------| | Where it stands | in front of the application | inside, during testing | inside, in production | | Decides on | the HTTP request as received | data flow through instrumented code | calls made while serving a request | | Route A (plain) | sees and can block | reports with code location, when tested | sees and can block | | Route B (decoded in app) | sees the encoded form | reports, when the flow stays within instrumented code | sees the final query | | Route C (worker, no HTTP) | no request to read | reports, when a test drives the worker | depends on whether non-HTTP processes are in scope | | Strongest at | volume, known patterns, no code change | developer-ready findings before release | stopping an exploit in production | | Needs from you | rule tuning | test coverage | a supported framework and an overhead budget | Read the table as a map of vantage points. No row says a category is wrong; each says where it stands. #### What a view at the operation adds {#operation} There is one more place to stand: the dangerous operation itself, in every Python process, with knowledge of where each part of the value came from. That is the position of es-python, the EyalSec Python runtime. You run the program with `es-python` in place of `python`; it marks data as it enters from the sources you switch on (network, files, standard input, environment, and optionally rows read back from a database), follows it through the program as it runs, and checks it at the operation itself: the moment the query is sent, the command is run or the file is opened. For the three routes, that means: - **Route A and B** are the same event. By the time `execute` runs, the string is one concrete argument, and the part that came from the network is known, however it was decoded or assembled on the way. - **Route C** is reported in the worker, because detection runs inside the program, not beside the request: any process started with es-python is covered, with or without HTTP. With the [db_rows source](https://eyalsec.com/docs/taint-sources#db-rows) on, a value read back from the database is followed too, which covers the stored variant. - **The fixed code is silent.** A bound parameter never enters the SQL text, so a correctly parameterized query produces no event. Each finding is an [event](https://eyalsec.com/docs/how-it-works#event): the operation reached, the value, where it entered and the call path. It can be reported only, or stopped under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise). The limits belong in the same breath: - **An event is a real flow, not proof of exploitability.** If earlier code compared the value with a fixed list, the flow happened and is harmless. The event gives you what you need to judge. - **Dataflow only.** A missing authorization check, a missing CSRF token or a protocol-level flaw is not untrusted data reaching an operation, so it is outside this view. A WAF and a review process cover ground here that es-python does not. - **It costs CPU.** Following data takes work, and the cost grows with the sources you switch on. Measure it on your own workload. - **It sees what runs.** Like IAST, a path nothing exercises produces nothing. - **Blocking has precise semantics.** At almost every operation the error is raised before the operation runs; at a small number that cannot be interrupted part-way, it is raised immediately after ([Report and Raise](https://eyalsec.com/docs/report-and-raise#raise)). #### How they fit together {#together} A reasonable arrangement uses each for what it is strongest at: 1. **Keep the WAF** for volume, known patterns and virtual patching while a fix is in progress. 2. **Use IAST or runtime tracking in CI** so flows are found with code context before release. Running a test suite under es-python is one way to do that; see [the pytest tutorial](https://eyalsec.com/research/running-your-pytest-suite-under-the-eyalsec-python-runtime). 3. **Decide what watches the processes that serve no HTTP.** Workers and scheduled jobs handle the same untrusted data as the web tier, later and with fewer observers. 4. **Fix the code.** Every tool in this article exists because of concatenated strings like the one in `find_items`. Parameterize, allowlist identifiers, and the routes above stop mattering. For the model behind events, see [How EyalSec works](https://eyalsec.com/docs/how-it-works); for the vulnerability class used here, [SQL injection](https://eyalsec.com/vulnerabilities/sql-injection); for cost, [pricing](https://eyalsec.com/pricing). If you would like to see what the operation-level view shows on your own services, next to the tools you already run, you can [book a live demo](https://eyalsec.com/contact). ### Command injection through subprocess in Python: shell=True, shlex and argument injection URL: https://eyalsec.com/research/command-injection-through-subprocess-in-python | Published: 2026-10-07 | Updated: 2026-10-07 | Author: EyalSec Research | Tags: python, command-injection Command injection in Python usually comes from one of three mistakes: running a command through a shell with `shell=True` and a string built from input, quoting that string incorrectly with `shlex`, or passing an attacker-controlled value as an argument to a program that treats a value beginning with a dash as an option. The first is well known; the second and third catch careful developers who thought they had avoided the problem. This article covers all three, plus cases that look safe and are not, with the fix for each. The two weaknesses in play are [CWE-78: OS Command Injection](https://cwe.mitre.org/data/definitions/78.html), where input changes which command runs, and [CWE-88: Argument Injection](https://cwe.mitre.org/data/definitions/88.html), where input changes the options of a fixed command. Both sit under OWASP's [A05:2025 Injection](https://top10.owasp.org/2025/A05_2025-Injection/). #### Mistake 1: shell=True with a built string When you pass `shell=True`, Python hands your string to `/bin/sh`, which interprets shell metacharacters: `;`, `|`, `&`, `$()`, backticks, redirections and more. If any part of that string came from input, those characters let the input run its own commands. ```python import subprocess # Vulnerable: input is interpreted by the shell def make_thumbnail(name): subprocess.run(f"convert {name} -resize 100x100 thumb.png", shell=True) ``` The [subprocess documentation](https://docs.python.org/3/library/subprocess.html#security-considerations) states the rule: when the shell is invoked via `shell=True`, "it is the application's responsibility to ensure that all whitespace and metacharacters are quoted appropriately to avoid shell injection vulnerabilities." The fix is almost always to not use a shell. Pass the command as a list of arguments, and `subprocess` executes the program directly with no shell in between: ```python def make_thumbnail(name): subprocess.run(["convert", name, "-resize", "100x100", "thumb.png"]) ``` The docs put the same point positively: without a shell, "all characters, including shell metacharacters, can safely be passed to child processes," because they go to the program as literal argument text rather than being parsed by a shell. You lose shell features such as pipes and globbing, which is the point: if you need a pipeline, build it with `subprocess` pipes or multiple calls, not by handing a string to `sh`. #### Mistake 2: shlex.quote, used slightly wrong Sometimes a shell really is needed. `shlex.quote()` exists for that: it returns a shell-escaped version of a string so it can be used as a single token. The mistake is to then wrap the already-quoted result in your own quotes. ```python import shlex import subprocess value = shlex.quote(user_value) # Correct: the quoted token stands on its own subprocess.run(f"printf '%s' {value}", shell=True) # Wrong: wrapping it in double quotes re-exposes the content subprocess.run(f'printf "%s" "{value}"', shell=True) ``` We ran both forms. `shlex.quote("$(some-command)")` returns the string wrapped in single quotes, which the shell treats as literal in the first call. In the second call, the surrounding double quotes turn the single quotes into literal characters and the shell evaluates what is inside, so the substitution runs. The rule from the [shlex docs](https://docs.python.org/3/library/shlex.html#shlex.quote) is to let the quoted value stand as its own token and add no quotes of your own. The docs also warn that `shlex.quote()` targets POSIX shells and is "not guaranteed to be correct" on non-POSIX shells, so keep it off Windows `cmd`. The safer path is still the argument list from mistake 1. #### Mistake 3: argument injection (a value that starts with a dash) This is the one that catches people who did everything right about shells. You use an argument list, no `shell=True`, no metacharacter problem. But a value that begins with `-` or `--` is read by the target program as an option, not as data, because the program parses its own arguments and cannot tell which ones you intended as data. ```python import subprocess # Looks safe: a list, no shell. Still exposed if filename starts with a dash. def show(repo, filename): subprocess.run(["git", "-C", repo, "show", f"HEAD:{filename}"]) ``` Here `filename` is prefixed, so it cannot begin with a dash, which closes that path. But many call sites pass a value straight through. A value like `--output=/path` or a program-specific flag changes what the command does: writing a file, enabling a feature, reading a different input. Tools commonly abused this way include `git`, `curl`, `wget`, `tar`, `rsync`, `find`, `ssh` and anything that takes option flags, which is nearly everything. Two fixes, best used together: 1. **Use the end-of-options marker.** Most well-behaved programs treat `--` as "everything after this is data, not options." Git documents `--end-of-options` for exactly this and advises placing a disambiguating `--` when handling "random user-input" ([git CLI conventions](https://git-scm.com/docs/gitcli)). ```python subprocess.run(["git", "-C", repo, "show", "--", filename]) ``` Check the specific tool's manual, because `--` support and its meaning vary. 2. **Validate or pin the value's shape.** If an argument should be a ref, a filename or a number, enforce that: reject a leading dash, or build the value so it cannot start with one. The `f"HEAD:{filename}"` form above is an example of pinning a prefix. `ssh` deserves a special mention because it is a double trap. Its [manual](https://man.openbsd.org/ssh) says that if a command is given, "the arguments will be appended to the command, separated by spaces, before it is sent to the server to be executed." So the remote side runs the joined string through a shell. An argument list protects you locally and does nothing remotely: whatever you pass is reassembled and interpreted by the remote shell. Build the remote command as carefully as a local `shell=True` string, and remember that `ssh` options on the local side are themselves an argument-injection surface. #### Cases that look safe but are not A few patterns pass a quick review and still carry risk: - **`shlex.split()` then join into a shell string.** Splitting input into tokens and then re-joining them into a command for `shell=True` puts the metacharacters right back. Splitting is not sanitizing. - **A hardcoded program name with an attacker-controlled argument.** `["ping", host]` looks fixed, but if `host` can be `-f` or a flag your platform's `ping` accepts, it changes behaviour. Argument injection does not need a shell. - **An "allowlisted" subcommand with an unchecked tail.** Checking that the first word is `git` and passing the rest through unchanged leaves every git option open. - **Environment-driven execution.** Values from environment variables and command-line arguments are attacker-influenced when something upstream builds the environment from input. A `PATH` or `LD_PRELOAD` an attacker can set redirects which binary actually runs. - **`os.system`, `os.popen`, `commands`-style helpers.** These always go through a shell. Prefer `subprocess` with a list. #### How to find and confirm these Grep for `shell=True`, `os.system`, `os.popen`, `subprocess.` with an f-string or `+` in the first argument, and `shlex`. Bandit flags `shell=True` and the `os` shell helpers. For argument injection, the harder review is every `subprocess` call with a list where an element comes from input and is not prefix-pinned or separated by `--`. Static search finds the call but not the dataflow: it cannot tell whether the value in `host` or `filename` came from a request, a config constant, or a database row, and it does not see a call reached through an alias or built inside a library. Runtime taint tracking answers that. es-python, the EyalSec Python runtime, follows untrusted data through the program as it runs and checks it at the command operation itself, so it reports a process launch only when attacker-controlled data actually reached it, with the path from where it entered. Because the check is at the operation, it is indifferent to how the command string was assembled, and a fixed command with no tainted data never fires. See [How EyalSec works](https://eyalsec.com/docs/how-it-works#sink) and the [command injection](https://eyalsec.com/vulnerabilities/command-injection) overview. The habits that prevent almost all of this are small: default to an argument list and no shell, reach for `shlex.quote` only when a shell is unavoidable and then add no quotes of your own, and treat any value that could start with a dash as an option until you have pinned its shape or passed `--`. If you want to see which process launches on your own services actually receive untrusted data, you can [book a live demo](https://eyalsec.com/contact). ### The Django column-alias SQL injection story: CVE-2024-42005 and CVE-2025-57833 URL: https://eyalsec.com/research/django-column-alias-sql-injection-cve-2024-42005-cve-2025-57833 | Published: 2026-10-07 | Updated: 2026-10-07 | Author: EyalSec Research | Tags: python, django, sql-injection CVE-2024-42005 and CVE-2025-57833 are two severe SQL injection vulnerabilities in Django, the most widely deployed Python web framework. Both share one root cause: a value that an application lets a client choose is used as a column alias, and column aliases are written into the SQL as identifiers, which the database driver's parameter binding cannot protect. This article walks through what each CVE is, how the injection reaches the query, why it lives in a dependency rather than in application code, and what that means for how teams watch the code they did not write. #### What the two CVEs are Both were published in Django's own security releases and are recorded in the National Vulnerability Database as CWE-89, SQL injection. **CVE-2024-42005** is a "Potential SQL injection in `QuerySet.values()` and `values_list()`", disclosed in [Django's 6 August 2024 security release](https://www.djangoproject.com/weblog/2024/aug/06/security-releases/). Django's release notes describe it precisely: `values()` and `values_list()` on a model with a `JSONField` "were subject to SQL injection in column aliases, via a crafted JSON object key as a passed `*arg`." The Django project rated it severity "high". [NVD](https://nvd.nist.gov/vuln/detail/CVE-2024-42005) scores CVE-2024-42005 at 7.3 (High); CISA's enrichment rates it 9.8 (Critical). It was fixed in Django 5.0.8 and 4.2.15. **CVE-2025-57833** is a "Potential SQL injection in `FilteredRelation` column aliases", disclosed in [Django's 3 September 2025 security release](https://www.djangoproject.com/weblog/2025/sep/03/security-releases/). The notes read: `FilteredRelation` "was subject to SQL injection in column aliases, using a suitably crafted dictionary, with dictionary expansion, as the `**kwargs` passed to `QuerySet.annotate()` or `QuerySet.alias()`." Django rated it "high"; [NVD](https://nvd.nist.gov/vuln/detail/CVE-2025-57833) scores it at 8.1 (High). It was fixed in Django 4.2.24, 5.1.12 and 5.2.6. For transparency: both advisories credit Eyal Gabay of EyalSec, publisher of this site; our thanks to Django's security team for fixing both. More useful to a Django team: the bug's shape, which recurs. #### How a column alias reaches the SQL Django's ORM is, correctly, immune to classic SQL injection in the common case. When you filter on a value, the value becomes a bound parameter: the SQL text carries a placeholder, and the database driver sends the value separately. The [Django security documentation](https://docs.djangoproject.com/en/5.2/topics/security/#sql-injection-protection) states the guarantee plainly: "A query's SQL code is defined separately from the query's parameters." A column alias is different. An alias is the name a selected expression is given in the query, the part after `AS`. Names are not values. A database cannot accept an identifier as a bound parameter, so Django has to write the alias into the SQL text itself, quoted as an identifier. If the alias contains a closing quote, everything after it is read by the database as more SQL. The two CVEs are the same mechanism reached through two different ORM features: - In CVE-2024-42005, the alias of a `JSONField` key lookup is derived from the key you pass to `values()`. If the application lets a client choose which JSON key to project, the client chooses an alias. - In CVE-2025-57833, the alias is a key in the `**kwargs` dictionary passed to `annotate()` or `alias()` for a `FilteredRelation`. If the application builds that dictionary from client input, the client chooses an alias. Here is what the application side of each looks like. Neither snippet contains raw SQL, and neither looks wrong in review: ```python from django.db.models import Count, FilteredRelation, Q # CVE-2024-42005 shape: group a report by a client-chosen JSON key def breakdown(request): key = request.GET["group_by"] return list(Report.objects.values(f"data__{key}").annotate(n=Count("id"))) # CVE-2025-57833 shape: named filtered relations built from a client dictionary def books(request, filters): rels = { name: FilteredRelation("author", condition=Q(author__name=value)) for name, value in filters.items() } return Book.objects.annotate(**rels) ``` We reproduced both shapes against the last vulnerable and first fixed Django 4.2 releases, using SQLite and a harmless marker string rather than an attack. On Django 4.2.14, the first function wrote the client's key verbatim inside the quoted alias of the generated `SELECT ... AS "data__..."`, while the JSON path itself stayed a bound parameter. A key containing a double quote ended the alias early. On 4.2.15 the same call raised `ValueError`. On Django 4.2.23, the second function placed the dictionary key, unquoted, as the alias of the joined table in the `INNER JOIN` clause, while `value` stayed a bound parameter. On 4.2.24 it raised `ValueError`. The defensive takeaway is the same in both: a value that looks like "just a field name" is interpolated into SQL as an identifier, and parameterization never touches it. The value you were careful to bind was safe all along; the name next to it was not. #### Why the fix is one line, and what that tells you Django has validated aliases since CVE-2022-28346 (April 2022), which added a `check_alias()` method and a `FORBIDDEN_ALIAS_PATTERN` that rejects quotation marks, whitespace, semicolons and SQL comments in an alias. The fixes for both later CVEs, visible in the public commits linked from [Django's security archive](https://docs.djangoproject.com/en/dev/releases/security/), add a single `self.check_alias(...)` call on a code path that reached alias construction without one: ```python # CVE-2024-42005 fix, in Query.set_values() if fields: for field in fields: self.check_alias(field) ... # CVE-2025-57833 fix, in Query.add_filtered_relation() def add_filtered_relation(self, filtered_relation, alias): self.check_alias(alias) ... ``` The validator existed; some call paths bypassed it, easy to miss in a large ORM. Alias injection is not one bug that got fixed; it is a class. Django has shipped several entries in the same family, including CVE-2022-28346, CVE-2025-59681, CVE-2025-13372 and CVE-2026-1287, each closing a new path into alias construction. A team that treats "we upgraded past that one CVE" as closure for the class will be surprised by the next path. #### Why this lives in a dependency, not in your code An application developer did nothing wrong at the SQL layer here. They used the ORM, which is the recommended, injection-safe interface. The flaw is in how the framework assembled the query from an API that accepted a developer-supplied name, where "developer-supplied" quietly became "client-supplied" because the application passed a request field through. This is the uncomfortable property of a dependency vulnerability: the dangerous operation runs inside `site-packages`, built by code you did not write and do not review, from data your own code handed in. Static analysis of your repository sees a call to `QuerySet.values()`, which is not a finding. A software composition scanner can tell you that an installed Django version has a known CVE, but not whether any request on your system actually drives attacker-controlled data into the vulnerable path. The 2025 OWASP Top 10 puts both halves of this problem near the top: [A05 Injection](https://top10.owasp.org/2025/A05_2025-Injection/) and [A03 Software Supply Chain Failures](https://top10.owasp.org/2025/A03_2025-Software_Supply_Chain_Failures/), the latter ranked first in the community survey. #### What your team can do A few concrete practices follow from the shape of these bugs: 1. **Treat framework "name" parameters as values when a client can influence them.** Any place you pass a request field as a field name, column, alias, ordering key or JSON key into the ORM is a candidate. Allowlist against a fixed set of known columns; pass only names from that set. 2. **Upgrade promptly, and watch the whole class.** Keep Django current so a fixed release is in place, and when a new alias injection CVE lands, check your own code for the pattern it describes rather than only bumping the version. 3. **Get evidence from the running system, not only from a source scan.** The question that matters is not "do we call `values()`" but "does attacker-controlled data ever reach the query the framework builds". That is a runtime dataflow question. #### What es-python showed on these two CVEs On the last point: the EyalSec Python runtime, es-python, follows untrusted data through the program as it runs, including through framework and library code, and checks it at the operation itself, the moment the program sends a query. Because the check happens where the query is issued rather than at the network edge or in your repository, it does not matter whether the dangerous string was assembled in your module or deep inside a dependency. We measured this directly. For each of the two CVEs we wrote a proof of concept that reconstructs the pre-fix data flow the advisory describes, marked the attacker-supplied input as untrusted, and ran it under es-python. In both cases es-python reported untrusted data reaching the database query operation, named where it entered, and, with blocking switched on, stopped the query before it ran. The same exercise covered every Django SQL injection advisory in the OSV database at the time (17 of 17 flagged). Be clear about what that number is: it measures detection of known, already-fixed bugs, not discovery of new ones, and it says nothing about the large share of Django advisories (CSRF, authorization, timing, resource exhaustion) that are not a flow of untrusted data into a dangerous operation at all. You can read how sources, sinks and events fit together on [How EyalSec works](https://eyalsec.com/docs/how-it-works), what blocking does on [Report and Raise](https://eyalsec.com/docs/report-and-raise#raise), and the broader class on [SQL injection](https://eyalsec.com/vulnerabilities/sql-injection). Whether or not you ever run a runtime sensor, the actions are the same: upgrade Django, allowlist anything a client can use as a name, and look for the pattern in your own code each time a new alias CVE is published. If you run a large Python backend and want to see this on your own code, you can [book a live demo](https://eyalsec.com/contact). ### SQL injection in Django, Flask and FastAPI in 2026: the patterns that still ship URL: https://eyalsec.com/research/sql-injection-in-django-flask-and-fastapi | Published: 2026-10-07 | Updated: 2026-10-07 | Author: EyalSec Research | Tags: python, django, flask, fastapi, sql-injection SQL injection still ships in Python web backends in 2026, and almost never through the ORM's ordinary filter methods. It comes in through the escape hatches (`raw()`, `extra()`, `RawSQL`, `cursor.execute()` and SQLAlchemy `text()`) when a query is built with string formatting, and through identifiers such as column names, sort keys and aliases, which parameter binding cannot protect at all. This article lists the patterns that keep appearing in Django, Flask and FastAPI code, shows the fix for each, and explains how to find them in an existing codebase. #### Why an ORM does not make you immune The ORMs Python developers use are safe by construction for values. When you write `User.objects.filter(email=email)` in Django or `select(User).where(User.email == email)` in SQLAlchemy, the value travels as a bound parameter, separate from the SQL text. Django's security guide says it directly: "A query's SQL code is defined separately from the query's parameters" ([Django security](https://docs.djangoproject.com/en/5.2/topics/security/#sql-injection-protection)). Injection survives for two reasons. Every ORM offers a way to write SQL text directly, and real applications use it for reporting queries, performance work and features the ORM cannot express. And some parts of a query are not values: table and column names, sort direction, aliases and SQL keywords are part of the statement's structure and must be written into the text. The OWASP Top 10 for 2025 keeps [Injection at A05](https://top10.owasp.org/2025/A05_2025-Injection/), notes that 100% of tested applications were tested for some form of it, and counts more than 14,000 CVEs for SQL injection alone ([CWE-89](https://cwe.mitre.org/data/definitions/89.html)). #### Pattern 1: cursor.execute() with an f-string The most common pattern is the oldest. A developer drops to a raw cursor and builds the statement with an f-string, `%` or `.format()`: ```python # Vulnerable: the value becomes part of the SQL text def find_orders(cursor, customer): cursor.execute(f"SELECT id, total FROM orders WHERE customer = '{customer}'") return cursor.fetchall() ``` Fix it by passing the value as a parameter. The placeholder depends on the driver: `%s` for Django's `connection.cursor()` and psycopg, `?` for the standard library's `sqlite3`. ```python # Django connection.cursor() or psycopg def find_orders(cursor, customer): cursor.execute("SELECT id, total FROM orders WHERE customer = %s", [customer]) return cursor.fetchall() # sqlite3 def find_orders_sqlite(conn, customer): return conn.execute( "SELECT id, total FROM orders WHERE customer = ?", (customer,) ).fetchall() ``` Two near-misses look like fixes and are not. Quoting the placeholder (`'%s'`) is wrong: Django's docs warn explicitly against it ([Performing raw SQL queries](https://docs.djangoproject.com/en/5.2/topics/db/sql/)). And `cursor.execute(sql % params)` is string formatting with extra steps: the `%` operator runs in Python before the driver ever sees the query. Pass the parameters as the second argument, always. #### Pattern 2: Manager.raw() with formatting `raw()` returns model instances from hand-written SQL, so it is popular for reporting views. It accepts a `params` argument, and Django's docs state "Do not use string formatting on raw queries or quote placeholders in your SQL strings!" ```python # Vulnerable Person.objects.raw(f"SELECT * FROM myapp_person WHERE last_name = '{lname}'") # Fixed Person.objects.raw("SELECT * FROM myapp_person WHERE last_name = %s", [lname]) ``` #### Pattern 3: QuerySet.extra() `extra()` injects literal clauses into ORM-generated SQL. Django's reference calls it "an old API that we aim to deprecate at some point in the future" and says "We are no longer improving or fixing bugs for this method" ([QuerySet.extra](https://docs.djangoproject.com/en/5.2/ref/models/querysets/#extra)). Code that still uses it is often old code nobody has looked at in years, which is exactly where formatted `where=` strings hide. ```python # Vulnerable Entry.objects.extra(where=[f"headline LIKE '%%{term}%%'"]) # Fixed: express it with the ORM Entry.objects.filter(headline__contains=term) ``` If you cannot rewrite it yet, use `params=` for every value, and treat each remaining `extra()` call as debt with a ticket. #### Pattern 4: RawSQL expressions `RawSQL` lets you embed a SQL fragment in an annotation or filter. Its `params` argument is required precisely so values stay out of the text, and the [expressions reference](https://docs.djangoproject.com/en/5.2/ref/models/expressions/#raw-sql-expressions) warns that "you must escape any parameters that the user can control by using params." The mistake is to satisfy the signature with an empty tuple and format the value in anyway: ```python from django.db.models.expressions import RawSQL # Vulnerable: params is present but empty qs.annotate(score=RawSQL(f"SELECT score FROM ranking WHERE tag = '{tag}'", ())) # Fixed qs.annotate(score=RawSQL("SELECT score FROM ranking WHERE tag = %s", (tag,))) ``` #### Pattern 5: SQLAlchemy text() in Flask and FastAPI Flask and FastAPI applications usually reach the database through SQLAlchemy, and `text()` is the escape hatch. The [SQLAlchemy reference](https://docs.sqlalchemy.org/en/20/core/sqlelement.html#sqlalchemy.sql.expression.text) lists "backend-neutral support for bind parameters" as the first advantage of `text()` over a plain string, with named parameters written as `:name`. Wrapping an f-string in `text()` throws that advantage away: ```python from sqlalchemy import text # Vulnerable: text() around an f-string is still string building def get_user(session, username): return session.execute( text(f"SELECT id, email FROM users WHERE username = '{username}'") ).first() # Fixed: a named bind parameter def get_user_fixed(session, username): return session.execute( text("SELECT id, email FROM users WHERE username = :username"), {"username": username}, ).first() ``` The same applies to `text()` passed into `filter()`, `where()` or `order_by()` of an ORM query. In a FastAPI handler, a typed path or query parameter such as `user_id: int` is validated before your code runs, which helps for that parameter; a `str` parameter is still arbitrary text. #### Pattern 6: IN lists built by joining strings Variable-length `IN (...)` lists push people back to string building: ```python # Vulnerable ids_sql = ",".join(ids) cursor.execute(f"SELECT * FROM items WHERE id IN ({ids_sql})") ``` Generate one placeholder per value instead, or let the library expand the list: ```python # DB-API: one placeholder per value placeholders = ", ".join(["%s"] * len(ids)) cursor.execute(f"SELECT * FROM items WHERE id IN ({placeholders})", ids) # SQLAlchemy Core: an expanding bind parameter from sqlalchemy import bindparam, text stmt = text("SELECT * FROM items WHERE id IN :ids").bindparams( bindparam("ids", expanding=True) ) rows = session.execute(stmt, {"ids": ids}).all() ``` The f-string in the first fix is safe because it only interpolates placeholders the code generated itself. Guard the empty-list case in the DB-API version: PostgreSQL and MySQL reject `IN ()` as a syntax error, even though SQLite accepts it, so a test suite on SQLite will not catch it. #### Pattern 7: ORDER BY and other identifiers Sort columns, sort direction, table names and column lists are where "always use parameters" stops working, because a database cannot bind an identifier. `ORDER BY %s` with a parameter sorts by a constant string, which is a no-op, so developers format the column in: ```python # Vulnerable: an identifier taken from the request cursor.execute(f"SELECT id, name FROM products ORDER BY {sort} {direction}") ``` The fix is an allowlist that maps request values to identifiers your code chose: ```python SORTS = {"name": "name", "price": "price", "newest": "created_at"} DIRECTIONS = {"asc": "ASC", "desc": "DESC"} def list_products(cursor, sort, direction): column = SORTS.get(sort, "name") order = DIRECTIONS.get(direction, "ASC") cursor.execute(f"SELECT id, name FROM products ORDER BY {column} {order}") return cursor.fetchall() ``` In SQLAlchemy, map to column objects (`{"price": Product.price}`) and call `.asc()` or `.desc()` on them. If you truly need a dynamic identifier with psycopg, compose it with [`psycopg.sql.Identifier`](https://www.psycopg.org/psycopg3/docs/api/sql.html), which quotes it correctly, but an allowlist is still better: a correctly quoted column the user should not sort by can still leak information through ordering. Django's `order_by()` resolves names against model fields and rejects unknown ones, but it has not always been airtight: [CVE-2021-35042](https://nvd.nist.gov/vuln/detail/CVE-2021-35042) was SQL injection in `QuerySet.order_by()` "if order_by is untrusted input from a client". Allowlist there too. #### Pattern 8: names passed into the ORM as keyword arguments The newest pattern is the subtlest, because there is no SQL in the application at all. When a client controls the *names* an application passes to the ORM, the framework writes those names into the query. The column-alias CVEs in Django, [CVE-2024-42005 and CVE-2025-57833](https://eyalsec.com/research/django-column-alias-sql-injection-cve-2024-42005-cve-2025-57833), came from client-chosen JSON keys passed to `values()` and client-built dictionaries expanded into `annotate(**kwargs)`. [CVE-2025-64459](https://www.djangoproject.com/weblog/2025/nov/05/security-releases/) is the same family through a different door: `filter()`, `exclude()`, `get()` and `Q()` were injectable "when using a suitably crafted dictionary, with dictionary expansion, as the `_connector` argument". The antipattern all three reward is expanding a request into the ORM: ```python # Vulnerable shape: the client chooses keyword names Product.objects.filter(**request.GET.dict()) # Fixed: the code chooses the names, the client supplies values ALLOWED = {"category", "brand", "in_stock"} filters = {k: v for k, v in request.GET.items() if k in ALLOWED} Product.objects.filter(**filters) ``` Keep Django current for the framework-side fixes, and let only your code choose keyword names. #### How to find these in an existing codebase Start with a search for the escape hatches: `.raw(`, `.extra(`, `RawSQL(`, `cursor.execute(`, `text(`, `executescript(`, and `**request.` or `**data` near an ORM call. Bandit's [B608 check](https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html) flags SQL built by string operations. Review every hit for formatted values and request-controlled identifiers. Static search has a ceiling. It finds the call, not the data: it cannot tell whether the string reaching `execute()` holds request data, a constant, or something a worker read back from the database days later, nor see the call reached through an alias or built inside a dependency. That is where runtime evidence helps. es-python, the EyalSec Python runtime, follows untrusted data through the program as it runs and checks it at the operation itself, so it reports a query only when untrusted data actually reached the SQL text, with the path from where it entered. A correctly parameterized query never produces an event, because the value never enters the SQL string. See [How EyalSec works](https://eyalsec.com/docs/how-it-works#sink) and the [SQL injection](https://eyalsec.com/vulnerabilities/sql-injection) overview. #### A short checklist | Pattern | Fix | |---------|-----| | `cursor.execute()` with f-string or `%` | pass values as the second argument | | `raw()` with formatting | `params=` | | `extra()` | rewrite with ORM expressions; `params=` meanwhile | | `RawSQL` with formatting | `params` tuple with every value | | SQLAlchemy `text(f"...")` | `:name` bind parameters | | `IN` list by join | generated placeholders or `expanding=True` | | `ORDER BY` / column / table from input | allowlist to known identifiers | | `**request` into the ORM | allowlist keyword names; keep Django current | If you want to see which of these patterns actually receive untrusted data on your own services, you can [book a live demo](https://eyalsec.com/contact). ### Taint tracking explained for Python developers URL: https://eyalsec.com/research/taint-tracking-explained-for-python-developers | Published: 2026-10-07 | Updated: 2026-10-07 | Author: EyalSec Research | Tags: python, taint-tracking, sql-injection, command-injection Taint tracking is a way of following untrusted data through a program: data that enters from an untrusted source is marked "tainted," the mark travels with it as the program copies, joins and transforms it, and the program checks whether tainted data has reached a dangerous operation, called a sink. It is how you answer the question static search cannot: not "does this code call `execute`," but "does attacker-controlled data actually reach that `execute`." This article defines the four moving parts (sources, propagation, sinks, sanitizers), explains why observing it at runtime gives stronger evidence than matching a pattern, and is honest about what taint tracking cannot see. #### The four parts ##### Sources: where untrusted data enters A source is a place where data from outside the program arrives, where someone else could have chosen what it says. For a Python backend the usual sources are a network socket, a file, standard input, environment variables and command-line arguments, and code loaded from a location another user can write to. One source is easy to forget: data read back out of your own database. Your application stored it, but a previous request may have planted it, which is how stored cross-site scripting and second-order SQL injection work. Treating database reads as a source is what lets a tracker follow those delayed flows. ##### Propagation: the mark travels with the data Propagation is the heart of it. When tainted data is copied, sliced, concatenated, formatted, decoded or passed into a function, the result is tainted too. This is why encoding tricks do not help an attacker against a taint tracker: if a tainted value is base64-decoded, the decoded bytes are still tainted; if it is split and rejoined, the pieces carry the mark. The program's own transformations do not launder the data, because the mark follows the data through them. ```python raw = read_from_socket() # tainted at the source decoded = base64.b64decode(raw) # still tainted after decoding name = decoded.decode().strip() # still tainted after transforms query = "SELECT * FROM t WHERE n = '" + name + "'" # the query is now tainted ``` ##### Sinks: where tainted data becomes dangerous A sink is an operation that is risky when the data reaching it is attacker-controlled: issuing a database query, running a shell command, evaluating or importing code, deserializing bytes, opening a file path, or making an outbound request. A sink is not a bug by itself. Programs run commands and open files constantly. It becomes a finding when the value arriving at the sink is tainted. In the snippet above, the sink is the database query, and it has received a tainted string. ##### Sanitizers: what makes tainted data safe again A sanitizer is an operation that neutralises tainted data for a particular sink, so that reaching the sink is no longer dangerous. The clearest example is parameterization: a value bound as a query parameter never enters the SQL text, so it cannot change the statement's structure no matter what it contains. A well-placed sanitizer should clear the taint, or at least downgrade it, so that a correctly written program produces no finding. Sanitization is context-specific, and this is where precision matters. HTML-escaping a value makes it safe to place in HTML body text, but not safe as a shell argument or inside a SQL identifier. A good tracker treats a sanitizer as valid only for the sink class it actually protects, and stays cautious when it cannot prove safety: downgrade rather than suppress, so a value protected only under some conditions never looks as clean as one that was genuinely neutralised. #### Runtime evidence next to a pattern match You can approximate taint tracking statically, by reading source code and reasoning about how data might flow, and tools that do so are useful. But a running program settles questions that source code only raises. - **The dangerous string may not exist until runtime.** A payload assembled by decoding, by joining two inputs, or by a template expansion is not present in the source at all. At the moment the sink runs, the final value exists as one concrete argument, fully assembled however it got there. - **The call target may be chosen at runtime.** A sink reached through an alias, a dictionary dispatch, `getattr`, or a callable stored in a variable is on no literal call pattern, but at runtime it is a real call to a real function. - **Reachability is an observed fact.** A pattern match says "a risky call exists here." Runtime taint tracking says "attacker-controlled data did arrive at this call on this input, and here is the path it took." The second is a smaller, higher-signal set, because it excludes every risky call that untrusted data never actually reaches. That last point is also the honest limit on the strength of the claim, which the next section covers. #### The honest limits Taint tracking is not a universal bug finder, so ask any vendor, us included, where theirs stops. Four limits are worth stating plainly. **Reaching a sink is not proof of exploitability.** An event means attacker-controlled data arrived at a dangerous operation, with the trace to show it. That is a much stronger and smaller signal than "a sink exists here," but it is not the same as "this is attackable." Upstream logic can pin a tainted value, for example an equality or allowlist check that only lets through a fixed set of strings, so the flow is real while the attacker cannot actually choose the payload. The defensible framing is "confirmed flows, not maybes": the tracker hands you the flow and the path, and you judge whether the value was truly the attacker's to control. **It only sees dataflow bugs.** Whole classes of vulnerability are not a flow of attacker data into a dangerous call, and a taint tracker is silent on them by design: cross-site request forgery (a missing token check), broken authorization (a missing permission check), timing side channels, request smuggling (protocol parsing), and most denial-of-service (slowness, not a dangerous operation). In a measurement across the full history of one major framework's advisories, the categories that are a taint-to-sink dataflow (injection, path traversal, code execution) were caught, and the categories that are a missing check or a protocol flaw were not. An honest tool reports the second group as out of scope rather than inventing findings for it. **Coverage depends on what is watched.** A tracker follows data through the code and sinks it knows about. Data that leaves through an unwatched path, or into a library whose internals are not followed, can lose its mark. Knowing where coverage ends is part of using it well. **Watching data has a cost.** Following every byte of untrusted data as a program runs costs CPU. The cost scales with how many sources you track, so you pay for the tracking you enable and nothing for the sources you leave off. Measure it on your own workload rather than trusting a headline number. #### How this maps to EyalSec The definitions above are exactly the model behind es-python, the EyalSec Python runtime. It marks data from the [taint sources](https://eyalsec.com/docs/taint-sources) you switch on, follows it through the program as it runs, and records an [event](https://eyalsec.com/docs/how-it-works#event) when it reaches a [sink](https://eyalsec.com/docs/how-it-works#sink), with the source, the path and the value. It checks the data at the operation itself, so the evidence is a real flow on real input rather than a pattern match. A value that was correctly parameterized never fires, and a value made safe for the sink it reached is [suppressed](https://eyalsec.com/docs/suppressed-events) rather than shown. In [Report](https://eyalsec.com/docs/report-and-raise#report) mode nothing is blocked and the program runs unchanged; in [Report and Raise](https://eyalsec.com/docs/report-and-raise#raise) the operation is stopped. The glossary defines each term as the dashboard uses it, and [How EyalSec works](https://eyalsec.com/docs/how-it-works) walks the whole path end to end. For the concrete bug classes a tracker is strongest on, see the overviews of [SQL injection](https://eyalsec.com/vulnerabilities/sql-injection) and [command injection](https://eyalsec.com/vulnerabilities/command-injection). Taint tracking is a precise tool for a specific job: finding where untrusted data reaches a dangerous operation. Used with its limits in mind, it turns a pile of "a risky call exists here" into a short list of "data actually got here, on this input." If you want to see that list for your own services, you can [book a live demo](https://eyalsec.com/contact). ### Why pickle.loads is dangerous, and where it hides in Python apps URL: https://eyalsec.com/research/where-pickle-deserialization-hides-in-python-apps | Published: 2026-10-07 | Updated: 2026-10-07 | Author: EyalSec Research | Tags: python, insecure-deserialization, code-injection Loading a pickle that an attacker could have influenced is unsafe: the Python documentation states that malicious pickle data can run code while it is being loaded, which is why the module's own warning is "only unpickle data you trust." The risk in a real application is rarely a visible call on a request body. It hides inside cache backends, task queues, session stores and machine-learning model files, where a developer never typed the word pickle. This article explains the risk at a level useful for defending against it, shows where it hides in first-party Python, lists safer formats, and gives a way to find every load. The treatment here is deliberately defensive. The point is to recognise and remove the exposure, not to demonstrate an exploit. #### The one-sentence reason A pickle is not a passive data format the way JSON is. Restoring a pickled object can require importing a callable and invoking it, which is how pickle legitimately rebuilds instances of your own classes. The [pickle documentation](https://docs.python.org/3/library/pickle.html) is explicit: unpickling "permits the unpickler to import and invoke arbitrary code," so the module "is not secure." This maps to [CWE-502: Deserialization of Untrusted Data](https://cwe.mitre.org/data/definitions/502.html) and sits inside OWASP's [A08:2025 Software or Data Integrity Failures](https://top10.owasp.org/2025/A08_2025-Software_or_Data_Integrity_Failures/), which names CWE-502 among its mapped weaknesses. The same caution applies to pickle's relatives. The standard library's [`marshal`](https://docs.python.org/3/library/marshal.html) module "is not intended to be secure against erroneous or maliciously constructed data," and [`shelve`](https://docs.python.org/3/library/shelve.html) is backed by pickle, so loading a shelf from an untrusted source carries the same risk. The security question about any load is therefore not "is this data malformed" but "could anyone untrusted have influenced these bytes, directly or at some earlier point." If the answer is anything other than a firm no, pickle is the wrong format. #### Where it hides in first-party Python Few developers call `pickle.loads()` on a request body on purpose. The exposure comes from infrastructure that uses pickle under the hood, on data that turns out to be reachable by an attacker. ##### Caches Several cache backends serialize values with pickle, so a read is an unpickle. Django's documentation warns that if a filesystem cache location sits inside a web-served directory, "an attacker who gains access to the cache file can not only falsify HTML content, which your site will trust, but also remotely execute arbitrary code, as the data is serialized using pickle" ([Django cache framework](https://docs.djangoproject.com/en/5.2/topics/cache/)). The memcached backends serialize with pickle as well. Anything that can write to the cache store, a shared Redis or memcached instance on a flat network, a writable file path, a key you let a user influence, becomes a path to a dangerous load. ##### Task queues and message brokers Distributed workers serialize job payloads to send them over a broker. [Celery's security guide](https://docs.celeryq.dev/en/stable/userguide/security.html) states that "pickle is inherently insecure, and should be avoided whenever clients are untrusted or unauthenticated," and that its default serializer is JSON for that reason. A worker is also exactly the component an edge filter never sees, because it processes a job off the broker with no HTTP request in scope. If anyone who should not be trusted can place a message on the queue, a pickle serializer turns that into code execution on a worker. The standard library has the same edge: [`multiprocessing`](https://docs.python.org/3/library/multiprocessing.html) notes that `Connection.recv()` "automatically unpickles the data it receives, which can be a security risk unless you can trust the process which sent the message." ##### Session stores If session data is serialized with pickle and stored where integrity depends on a secret, a leak of that secret becomes code execution. Django deprecated its pickle session serializer in 4.1 and removed it in 5.0 "due to the risk of remote code execution" ([Django 4.1 release notes](https://docs.djangoproject.com/en/5.2/releases/4.1/)). Its [session docs](https://docs.djangoproject.com/en/5.2/topics/http/sessions/) explain the escalation for the signed-cookie backend: although the cookie is signed to prevent tampering, if `SECRET_KEY` leaks, "a SECRET_KEY leak immediately escalates to a remote code execution vulnerability" when pickle is the serializer. The bundled `JSONSerializer` avoids this entirely. ##### Machine-learning model files The format that catches teams by surprise in 2026 is the model file. A great deal of saved-model tooling is pickle underneath: - NumPy's [`load`](https://numpy.org/doc/stable/reference/generated/numpy.load.html) warns that object arrays use pickle, "which is not secure against erroneous or maliciously constructed data," and recommends `allow_pickle=False` for untrusted sources. - pandas [`read_pickle`](https://pandas.pydata.org/docs/reference/api/pandas.read_pickle.html) warns that "loading pickled data received from untrusted sources can be unsafe." - joblib's `load` warns that it "relies on the pickle module and can therefore execute arbitrary code," so it "should never be used to load files from untrusted sources." - PyTorch changed the default of `torch.load` to `weights_only=True` in its 2.6 release, which it called "an important security improvement measure," precisely because the old default unpickled freely. A model downloaded from a hub, pulled from object storage, or uploaded by a user is untrusted input. Treating a `.pkl`, `.pt` or `.joblib` file as data because it holds weights is the trap. #### Safer options In order of preference: 1. **Use a data-only format.** For structured data, [`json`](https://docs.python.org/3/library/json.html) carries no code and cannot invoke anything on load. It handles a smaller set of types, which is the point. For configuration, use [`yaml.safe_load`](https://pyyaml.org/wiki/PyYAMLDocumentation), not `yaml.load` without a safe loader; current PyYAML requires the `Loader` argument specifically to stop the unsafe default. ```python import json def read_payload(data: bytes) -> dict: obj = json.loads(data) if not isinstance(obj, dict): raise ValueError("expected a JSON object") return obj ``` 2. **For ML weights, use a format that stores only tensors.** `safetensors` was designed to hold weights without executing anything on load, and `torch.load(..., weights_only=True)` restricts loading to tensors rather than arbitrary objects. 3. **If you only need integrity, sign the data and keep it data-only.** The pickle docs suggest signing with [`hmac`](https://docs.python.org/3/library/hmac.html) if you must ensure data was not tampered with. Signing proves the bytes came from you; it does not make an attacker-chosen object safe, so sign a JSON payload rather than a pickle, and compare digests with `hmac.compare_digest`. ```python import hashlib import hmac def verify(payload: bytes, signature: str, key: bytes) -> bool: expected = hmac.new(key, payload, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature) ``` If you genuinely cannot avoid pickle for a trusted internal channel, the documentation describes restricting what a custom `Unpickler.find_class()` will resolve to a small allowlist. Treat that as a last resort behind an authenticated, integrity-checked boundary, not as a way to make untrusted input safe. #### How to find every load Grep is the first pass. Search for `pickle.load`, `pickle.loads`, `cPickle`, `marshal.load`, `shelve.open`, `torch.load`, `joblib.load`, `numpy.load`, `pandas.read_pickle`, and `yaml.load(` without a safe loader. Bandit flags several of these directly. Then, for each hit, trace backwards: where do the bytes come from, and could anyone untrusted influence them at any point, including a value written to a cache or queue earlier. That backward trace is the hard part, and it is where static search runs out. It can show you the call; it cannot tell you whether the bytes reaching it originated from a socket, a user-writable file, or a database row a different request planted. Runtime evidence answers the question that grep cannot. es-python, the EyalSec Python runtime, follows untrusted data through the program as it runs and checks it at the operation itself, so a deserialization load is reported only when data that entered from an untrusted source actually reaches it, with the path from where it entered. Because the check sits at the load rather than at the network edge, it fires on a worker pulling a job off a broker just as it does on a web request, and on a load deep inside a library the same as one in your own module. See [How EyalSec works](https://eyalsec.com/docs/how-it-works#sink) and the [insecure deserialization](https://eyalsec.com/vulnerabilities/insecure-deserialization) overview. The short version: pickle is a fine way to move objects between processes you fully trust, and a liability anywhere untrusted bytes can reach it. Pick a data-only format for everything crossing a trust boundary, sign what needs integrity, and know which of your model files, caches and queues are pickle underneath. If you want to see which deserialization loads actually receive untrusted data on your own services, you can [book a live demo](https://eyalsec.com/contact). ### Why a WAF cannot see a SQL injection decoded inside the app URL: https://eyalsec.com/research/why-a-waf-cannot-see-a-sql-injection-decoded-inside-the-app | Published: 2026-10-07 | Updated: 2026-10-11 | Author: EyalSec Research | Tags: python, sql-injection, waf A WAF cannot see a SQL injection that is decoded inside the application because it inspects the request as it crosses the network edge, and the injection only becomes SQL after the application decodes the payload, assembles it from harmless pieces, or reads it from somewhere that is not an HTTP request. Nothing on the wire matches a signature, so the request passes and the dangerous string exists only inside the process. This article explains the structural reasons by category, with no vendor named, and what kind of visibility does see these flows. A WAF is a useful control and this is not an argument against running one. It is an argument about where it can and cannot see, so you can reason about the gap instead of assuming there is none. #### What an edge filter actually inspects An edge filter sits in front of the application and examines incoming HTTP traffic for known attack payloads and abnormal patterns. The decision is made on the request as received: headers, the query string, the body, as bytes on the wire. A managed SQL injection ruleset, for example, recognizes shapes like a quote followed by a boolean tautology and a comment marker, and blocks the request. The strength and the limit are the same fact. The filter sees exactly what arrives, and nothing about what the application then does with it. Everything below assumes the filter is working perfectly against the traffic it can read. The problem is not evasion of the rules; it is that the dangerous string does not exist yet when the rules run. #### When the payload is decoded inside the app Applications routinely decode input before using it: base64 in a token or cookie, percent-encoding, a JSON string field, a compressed or custom-encoded blob. If a value is benign in its encoded form and only becomes a SQL metacharacter sequence after the application decodes it, the request on the wire carries no signature. ```python import base64 def search(request): raw = request.GET["q"] # benign on the wire term = base64.b64decode(raw).decode() # becomes live SQL only here with connection.cursor() as cur: cur.execute("SELECT id FROM item WHERE name = '" + term + "'") # sink ``` The edge filter inspected `raw`, which is an innocuous base64 string. The injection exists only after `b64decode`, inside the process, where the filter has no presence. The same holds for any transform the application applies: URL-decoding, unwrapping a JSON field, replacing placeholder tokens, or decompressing. The fix for the code above is parameterization, and it is worth stating because it also explains why the code is the place to rely on. A bound parameter keeps the decoded term out of the SQL text entirely: ```python cur.execute("SELECT id FROM item WHERE name = %s", [term]) ``` The point for this article is that whether or not the code is fixed, the filter could never have known: the attack was not in the request it saw. #### When the payload is split across two fields A signature matches a contiguous pattern in one place. If an injection is assembled inside the application from two or more inputs that are each harmless on their own, no single field is a signature. ```python def report(request): column = request.GET["col"] # e.g. a column name op = request.GET["op"] # e.g. a comparison sql = f"SELECT * FROM doc WHERE {column} {op}" # reassembled in-app: sink with connection.cursor() as cur: cur.execute(sql) ``` Each parameter alone passes any per-field inspection. The dangerous string is produced by concatenation after both have been accepted. An edge filter inspecting requests field by field has no pattern to catch, because the pattern does not appear until the application builds it. (Note also that `column` and `op` are SQL text, not values: parameters cannot fix this one, which is the subject of a separate article on identifier and alias injection.) #### When the input never crosses the edge An edge filter inspects HTTP and HTTPS as it passes the perimeter toward your origin. A great deal of what a Python backend processes does not arrive that way: - A **background worker** pulls a job off a broker (Celery, RQ, a queue) and processes attacker-influenced content with no HTTP request in scope. - An **east-west** call between internal services never traverses the perimeter the edge filter guards. - A **scheduled task** reads a file, an object store or a third-party API. - A **non-HTTP protocol** (a raw socket service, a message consumer) is simply not HTTP traffic. In every case the data can still be attacker-controlled: a user uploaded the file, enqueued the job, or populated the record a worker later reads. But the request the edge filter is built to read either does not exist or already passed the perimeter earlier, stripped of the context that made it dangerous. Stored, second-order flows are the sharpest version: input accepted cleanly in one request is written to the database, then read back and used dangerously by a different code path minutes or days later. #### The through-line These are not three unrelated gaps. They are one property seen from three sides: an edge filter matches the request, and the dangerous value is a product of what the application does after the request is accepted. Decoding, reassembly and off-HTTP input all move the moment-of-danger past the edge. The 2025 OWASP Top 10 keeps [Injection at A05](https://top10.owasp.org/2025/A05_2025-Injection/) with the highest CVE count of any category, so this is not a corner case. Other categories of tool have their own version of the same limit. Static analysis reads source without running it, so a payload that only exists after a runtime decode is invisible to it. Dependency scanners know which installed packages have CVEs, but not whether attacker data ever reaches the flaw, and they have nothing to say about a bug in your own code. Agents scoped to the HTTP request lifecycle do not follow a job a worker pulls off a queue. Each watches a place that is not the place where untrusted data meets the dangerous operation. #### What does see these flows The one position from which decoding, reassembly and off-HTTP origin all become irrelevant is the dangerous operation itself. At the instant the program issues the query, runs the command or opens the path, the final string exists, fully assembled, however it got there. A detector that checks the data at that operation does not care whether the payload was base64 in a cookie, split across two form fields, or carried in a broker message, because by then it is one concrete argument to one call. That is where es-python, the EyalSec Python runtime, looks. It follows untrusted data through the program as it runs, from the source where it entered, through the decoding and concatenation your code performs, to the operation that uses it, and it checks it at the operation itself. An attacker cannot encode around it, because there is nothing in between to encode around. The evidence it produces is a real flow that happened on real input, with the path from entry to use, not a match on a signature. See [How EyalSec works](https://eyalsec.com/docs/how-it-works) and the [SQL injection](https://eyalsec.com/vulnerabilities/sql-injection) overview for the model, and the [taint sources](https://eyalsec.com/docs/taint-sources) page for the inputs it can treat as untrusted, including data read back out of a database, which is how second-order flows are caught. Keep the edge filter. It stops a large volume of unsophisticated traffic cheaply. Just keep "the WAF passed it" apart from "the application was not attacked". If you want to see which flows reach a sink on your own services, including the worker and east-west paths an edge never sees, you can [book a live demo](https://eyalsec.com/contact). ## Vulnerability classes, full text Every page under https://eyalsec.com/vulnerabilities, in page order, as published. ### SQL injection in Python URL: https://eyalsec.com/vulnerabilities/sql-injection | CWE-89 | OWASP A05:2025 Injection SQL injection is a flaw where data an attacker controls becomes part of the text of a SQL statement, so the database runs a query the developer never wrote. #### How it shows up in Python {#in-python} Every Python database driver can keep a statement and its values apart, but only when the code lets it. The bug is building the statement as a string. This f-string reads naturally and is exploitable: a `name` of `x' OR '1'='1` returns every row. ```python import sqlite3 def find_user(conn: sqlite3.Connection, name: str): cur = conn.execute(f"SELECT id, email FROM users WHERE name = '{name}'") return cur.fetchall() ``` The fix is a placeholder. The driver sends the value separately, so it never becomes SQL text: ```python def find_user(conn: sqlite3.Connection, name: str): cur = conn.execute("SELECT id, email FROM users WHERE name = ?", (name,)) return cur.fetchall() ``` psycopg and mysqlclient use `%s` placeholders. Passing the values as the second argument to `execute()` is safe; formatting them into the string with the `%` operator is the same bug as the f-string. Frameworks have the same split. Django's ORM methods bind values for you, but `raw()` and `extra()` take SQL text, and so does SQLAlchemy's `text()`: ```python User.objects.raw(f"SELECT * FROM auth_user WHERE username = '{username}'") User.objects.extra(where=[f"last_name = '{last}'"]) session.execute(text(f"SELECT * FROM orders WHERE ref = '{ref}'")) ``` Each has a parameterized form: ```python User.objects.raw("SELECT * FROM auth_user WHERE username = %s", [username]) User.objects.filter(last_name=last) session.execute(text("SELECT * FROM orders WHERE ref = :ref"), {"ref": ref}) ``` Placeholders only cover values. A table name, a column name or a sort direction cannot be bound, so a request parameter that picks the `ORDER BY` column needs an allowlist: ```python SORTABLE = {"created": "created_at", "total": "amount"} column = SORTABLE.get(request.GET.get("sort"), "created_at") rows = conn.execute(f"SELECT * FROM orders ORDER BY {column}").fetchall() ``` #### Why it is hard to see {#missed} The variants below are hard for tools that read source code or inspect traffic, as the injected SQL only exists while the program runs. - **Decoded inside the application.** A request carries `0~~OR~~1EQ1`, which contains no SQL syntax at all, and the code turns it into `0 OR 1=1` with two `replace()` calls before building the query. A web application firewall matching signatures in the request has nothing to match, and a static analyzer sees string replacement, not SQL. - **Reached through an alias.** `run = conn.cursor().execute` followed by `run(sql)` does not look like a call to `execute()` with a formatted argument, so a rule written for that shape stays quiet. - **Split across two fields.** Two request parameters that are harmless alone are joined inside the application into `name = '' OR 1=1 --`. No single field carries a signature. - **Second-order.** The value is stored safely today and read back next week by a report job that concatenates it into a query. No request carries the attack and no single function shows the flow. - **No HTTP request at all.** A Celery worker builds a query from a queue message. Tools scoped to the web request never see it. - **Inside a dependency.** Two severe Django SQL-injection CVEs have this shape. CVE-2024-42005 (CVSS 7.3 High per NVD; CISA rates it 9.8 Critical) let a crafted JSON key passed to `QuerySet.values()` or `values_list()` on a `JSONField` act as a column alias; CVE-2025-57833 (CVSS 8.1 High per NVD) did the same through `FilteredRelation` aliases in `annotate()` or `alias()`. Both are in Django itself, and es-python flags both, because the attacker's key reaches the SQL text whoever's code built it. #### What the es-python event looks like {#event} es-python reports when untrusted data reaches the text of a statement, at the call that sends it. The sink label depends on the library: `sqlite execute` for the standard `sqlite3` module, the driver's own query call for the PostgreSQL, MySQL, MariaDB and Oracle drivers that ship with es-python, `sql raw` for Django's `raw()` and `extra()`, and `sql injection` for SQLAlchemy's `text()`, asyncpg, mysql-connector-python, aiomysql, the Cassandra driver and clickhouse-driver. Every one carries the `sqli` [impact tag](https://eyalsec.com/docs/events#impact-tags). A value passed as a bound parameter never enters the statement text, so a correctly parameterized query produces no event. That is what makes an `sqli` event worth opening: attacker-controlled characters were part of the SQL the database was asked to parse. It is not proof of exploitability. If the code compared the value against a fixed list first, the flow is real and harmless, and the event shows you the value, its source and the stack trace so you can judge. The [severity](https://eyalsec.com/docs/events#severity) comes from the source and the sink. A query is one of the most damaging sinks, so the same flow is graded Critical when the data came from the network, Medium when it came from standard input or an environment variable, and Info when it came from a file only the program's owner can write. When EyalSec can prove the untrusted part could not change the statement, the event is [suppressed](https://eyalsec.com/docs/suppressed-events): kept, but out of the default view. Second-order flows appear when the [db_rows source](https://eyalsec.com/docs/taint-sources#db-rows) is on, which marks values read back from the database. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) on the label, the program gets a `RuntimeError` before the statement reaches the database. #### How to fix and prevent it {#fix} 1. Pass every value as a parameter. Keep f-strings, `%`, `+` and `format()` out of SQL. 2. Allowlist identifiers: map user choices to column and table names you wrote. 3. Treat `raw()`, `extra()`, `RawSQL`, `text()` and `cursor.execute()` with a computed string as review points, and search the codebase for them. 4. Give the application a database account that can only do what it needs, so a successful injection reaches less. 5. Keep Django, SQLAlchemy and your drivers current; Django's security team fixed both CVEs above. 6. Run es-python in Report mode with the [socket source](https://eyalsec.com/docs/taint-sources#socket) on to see which statements receive request data, then add Raise rules for the ones you understand. #### Classification {#classification} SQL injection is [CWE-89](https://cwe.mitre.org/data/definitions/89.html), Improper Neutralization of Special Elements used in an SQL Command. OWASP lists CWE-89 under [A05:2025 Injection](https://top10.owasp.org/2025/A05_2025-Injection/) in the Top 10 for 2025. #### Questions people ask {#faq} The questions that come up on forums and in reviews about this class, each answered in a few sentences. ##### Does the Django ORM protect me from SQL injection? For values, yes: `filter()`, `get()` and the other query methods bind values as parameters, so a value never becomes SQL text. The escape hatches take SQL text you wrote, so `raw()`, `extra()`, `RawSQL` and `cursor.execute()` are safe only when the values travel in `params`, and identifiers such as column names and sort keys need an allowlist because no parameter can carry them. Two Django CVEs, CVE-2024-42005 and CVE-2025-57833, were alias injections inside the framework itself, which Django's security team fixed promptly. ##### Can a WAF stop SQL injection in a Python app? A WAF stops the injection attempts it recognises in the request, a useful first layer. Its decision is made on the bytes on the wire, before the application decodes a payload, joins two harmless fields or reads a queue. es-python adds a check at the query itself, with the value that really reached it, whatever route it took. ##### How do I detect SQL injection in a running Django or Flask app? Run the application with es-python in Report mode with the network source on, and every statement that receives request data appears on the dashboard with the value, its source and the stack trace. A correctly parameterized query never fires, so the list is exactly the set of statements built from request text. Once you have judged them, a Raise rule stops the ones you want blocked before the statement reaches the database. ### Command injection in Python URL: https://eyalsec.com/vulnerabilities/command-injection | CWE-78, CWE-88 | OWASP A05:2025 Injection Command injection is a flaw where data an attacker controls ends up in an operating-system command the program runs, so the attacker gets to run commands of their own with the program's privileges. #### How it shows up in Python {#in-python} The classic shape is a shell command assembled from a string. A Flask route that converts an uploaded image by name: ```python import subprocess from flask import request @app.post("/thumbnail") def thumbnail(): name = request.form["name"] subprocess.run(f"convert uploads/{name} -resize 200x200 thumbs/{name}", shell=True, check=True) return "ok" ``` A `name` that contains `;` followed by a second command runs that command too, as the web server's user. `os.system()`, `os.popen()` and `subprocess` with `shell=True` all hand the whole string to `/bin/sh`, which treats `;`, `|`, `$( )` and backticks as syntax. The fix is to skip the shell and pass a list. Each element reaches the program as exactly one argument, and nothing in it is interpreted: ```python import re import subprocess from flask import request, abort SAFE_NAME = re.compile(r"[A-Za-z0-9_.]{1,64}") @app.post("/thumbnail") def thumbnail(): name = request.form["name"] if not SAFE_NAME.fullmatch(name) or name.startswith("."): abort(400) subprocess.run(["convert", "--", f"uploads/{name}", "-resize", "200x200", f"thumbs/{name}"], check=True) return "ok" ``` A list is not the whole answer. If an element starts with `-`, many programs read it as an option, which is argument injection: a "filename" that is really an option flag changes what the command does without any shell. Validate the shape, reject a leading dash, and put `--` before positional arguments when the program supports it. If you genuinely need a shell, `shlex.quote()` each value, and still prefer a list. The same bug exists one hop away. paramiko's `exec_command()` sends a string to a remote shell, so `client.exec_command(f"tar czf /backup/{tenant}.tgz /data/{tenant}")` is command injection on the remote host. #### Why it is hard to see {#missed} - **Aliases and dispatch tables.** `run = os.system` then `run(cmd)`, or `ACTIONS = {"run": os.system}` then `ACTIONS[name](arg)`, or `getattr(os, fn)(arg)` with both parts from a request. Rules that match a literal `os.system(` call do not see these, and a static tool cannot prove which function a runtime key selects. - **A custom parser in between.** Data decoded by a hand-written binary or text parser before it reaches the command. Taint tracking that only follows data through libraries it knows loses it at the parser. - **A successful injection does not crash.** A coverage-guided fuzzer looks for crashes and new code paths. `subprocess.run("convert " + arg, shell=True)` with an injected `; echo` exits 0, so the fuzzer has nothing to report. - **Workers with no web request.** A job pulled from a queue builds a command from the message. Tools scoped to the HTTP request lifecycle never look at it. - **The sandbox dilemma.** A system-call filter sees `execve` and a path, not the command string. It can block every command (and break the legitimate ones) or allow them all. #### What the es-python event looks like {#event} es-python reports when untrusted data reaches a command, at the call that would run it: `os system` for `os.system()`, `subprocess exec` and `os spawn` for `subprocess` (which `os.popen()` uses too), `os execv` and `os execve` for the `exec` family, and `command injection` for paramiko's `exec_command()`. These carry the `command injection` [impact tag](https://eyalsec.com/docs/events#impact-tags). When the shape of the command says more, a sharper label is added beside the base event: | Label | What it means | |-------|---------------| | `subprocess shell` | the program ran `sh -c` and the command string was untrusted: the `shell=True` shape | | `shell injection` | an untrusted argument contains a shell metacharacter | | `argument injection` | an untrusted argument starts with `-`, so the program may read it as an option | | `env injection` | an untrusted value reached the new process's environment | | `path hijack` | that environment value was a variable the loader consults, such as `PATH` | `argument injection` also fires on a value that legitimately starts with a dash, such as a negative number, so read it as "check this argument", not as a verdict. The [severity](https://eyalsec.com/docs/events#severity) comes from where the data came from: network data reaching a command is Critical, and the same command fed from the program's own command line is Info, because whoever typed that line could run the command anyway. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise), the program gets a `RuntimeError` at the call; [Report and Raise](https://eyalsec.com/docs/report-and-raise) says exactly when it is raised. The fixed version above still hands request data to a program, so es-python still records the base `subprocess exec` event for it. What changes is the shape: no `subprocess shell`, and with the validation in place no `shell injection` or `argument injection`. When EyalSec can prove the value could not break out of its argument, the event is [suppressed](https://eyalsec.com/docs/suppressed-events) rather than shown. #### How to fix and prevent it {#fix} 1. Skip the shell: no `shell=True`, no `os.system()`, no `os.popen()`. Pass an argument list. 2. Prefer a library call to a command. `shutil`, `tarfile`, Pillow and the `zipfile` module replace most `cp`, `tar`, `convert` and `unzip` invocations. 3. Validate every untrusted argument against the shape you expect, reject a leading `-`, and use `--` where the program supports it. 4. Keep untrusted values out of the child's environment. 5. Run es-python in Report mode with the [socket source](https://eyalsec.com/docs/taint-sources#socket) on, find every command that receives request data, then raise on `subprocess shell` first: it is the shape that is almost never intended. #### Classification {#classification} Command injection is [CWE-78](https://cwe.mitre.org/data/definitions/78.html), Improper Neutralization of Special Elements used in an OS Command; the option-injection variant is [CWE-88](https://cwe.mitre.org/data/definitions/88.html), Argument Injection. OWASP maps both to [A05:2025 Injection](https://top10.owasp.org/2025/A05_2025-Injection/). #### Questions people ask {#faq} The questions that come up on forums and in reviews about this class, each answered in a few sentences. ##### Is subprocess with a list always safe? A list keeps the shell out, so metacharacters in an argument cannot start a second command, and that removes the common case. An argument that begins with a dash can still be read as an option by the program you call, and a program that runs its arguments through another shell, such as `ssh`, reintroduces the problem on the far side. es-python labels the first shape `argument injection` and the shell shape `subprocess shell`, so you see which one reached the call. ##### Is shlex.quote enough to make shell=True safe? No: `shlex.quote()` is for POSIX shells only and protects a single word, so it does nothing on Windows, nothing for a value placed where the shell expects an option, and nothing once the string is parsed again by a second program. Passing a list without a shell removes the quoting problem instead of patching it. Keep `shlex.quote()` for the one case where a shell is unavoidable, and validate the value as well. ##### How do I detect command injection at runtime in Python? Run the program with es-python and it reports every command built from untrusted data at the call that would run it, with the value, its source and the stack trace, labelled by shape: `subprocess shell`, `shell injection` or `argument injection`. The severity follows the source, so request data reaching a command is Critical and the program's own command line is Info. A Raise rule stops the call with a Python RuntimeError before the command runs. ### Code injection in Python URL: https://eyalsec.com/vulnerabilities/code-injection | CWE-94, CWE-95, CWE-470 | OWASP A05:2025 Injection Code injection is a flaw where data an attacker controls is run as Python code, through `eval()`, `exec()`, a dynamic import or a name-based lookup, so the attacker can do anything the program can. #### How it shows up in Python {#in-python} Python makes running a string as code a one-liner, and that is the problem. The most common misuse is not a calculator; it is `eval()` used as a parser for something that looks like Python data: ```python from flask import request @app.post("/saved-filters") def save_filters(): filters = eval(request.form["filters"]) store_filters(current_user(), filters) return "saved" ``` The form value was meant to be `{'status': 'open'}`. It can equally be an expression that imports a module and calls into it, which is everything the program itself can do. Passing `{"__builtins__": {}}` as the globals does not make this safe: attribute walks from any literal reach the builtins again. Parse data as data instead: ```python import json from flask import request, abort ALLOWED = {"status", "owner", "label"} @app.post("/saved-filters") def save_filters(): filters = json.loads(request.form["filters"]) if not isinstance(filters, dict) or not set(filters) <= ALLOWED: abort(400) store_filters(current_user(), filters) return "saved" ``` The second shape is choosing code by name. A plugin loader or an action dispatcher that takes the name from a request lets the attacker pick a module or a function you never meant to expose: ```python import importlib def load_exporter(fmt: str): return importlib.import_module(fmt).export def handle(action: str, payload: dict): return getattr(handlers, action)(payload) ``` Map names to code you chose, and fail closed on anything else: ```python import importlib EXPORTERS = {"csv": "exporters.csv_export", "xlsx": "exporters.xlsx_export"} ACTIONS = {"archive": handlers.archive, "restore": handlers.restore} def load_exporter(fmt: str): if fmt not in EXPORTERS: raise ValueError(f"unknown format {fmt!r}") return importlib.import_module(EXPORTERS[fmt]).export def handle(action: str, payload: dict): if action not in ACTIONS: raise ValueError(f"unknown action {action!r}") return ACTIONS[action](payload) ``` The same applies to `setattr(obj, field, value)` with both parts from a request (mass assignment), to `ctypes.CDLL()` given an untrusted path, and to a debug console such as `code.InteractiveConsole` fed from a socket. #### Why it is hard to see {#missed} - **The call target is chosen at runtime.** `getattr(os, fn)(arg)` with `fn` from the request names no dangerous function anywhere in the source, so a scanner cannot connect it to `os.system`. - **East-west input.** A service picks a module by a name it received from another internal service. The request never crosses the edge, so a CDN or web application firewall in front of the public site never sees it. - **Indirection.** `run = eval` in one module and `run(expr)` in another. Pattern rules keyed on the literal call do not follow the alias. - **Data that only becomes code later.** A value stored today and passed to `exec()` by a scheduled job next week. No single request or function shows the whole flow. #### What the es-python event looks like {#event} es-python reports untrusted data reaching an operation that turns it into code: `eval`, `exec` and `compile` for the builtins, `__import__` for an import by computed name, `interactive console` when a console runs untrusted input, and `ctypes dlopen` when a library is loaded from an untrusted path. These carry the `rce` [impact tag](https://eyalsec.com/docs/events#impact-tags), one of the classes the dashboard always shows in its critical colour. An `importlib.import_module()` call with an untrusted name is reported where the import system looks that name up on disk, as a file-system event. Name-based access gets its own labels. `setattr` and `monkey patch` (a write onto a module or a class, which replaces code rather than data) carry the `unsafe reflection` tag. An untrusted name passed to `getattr()` is reported as `getattr` and tagged as template injection, because walking attributes is how template-injection payloads escape; EyalSec keeps those events only when the name reaches an introspection attribute such as `__class__` or `__globals__`, and suppresses the rest. See [server-side template injection](https://eyalsec.com/vulnerabilities/server-side-template-injection). One practical note: `ast.literal_eval()` and `ast.parse()` cannot run code, but they compile their input to a syntax tree, so on untrusted input es-python reports them as `compile`. If you rely on them deliberately, record that decision with a [rule](https://eyalsec.com/docs/rules) for that call site. The [severity](https://eyalsec.com/docs/events#severity) is Critical when the data came from the network. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise), `eval()`, `exec()` and `compile()` raise a `RuntimeError` before anything is compiled, so the injected code never runs. #### How to fix and prevent it {#fix} 1. Keep untrusted input out of `eval()`, `exec()` and `compile()`. Parse data with `json`, configuration with `tomllib` or a schema library. 2. For expressions users really need (a pricing formula, a filter), use a purpose-built evaluator with an explicit grammar, not Python. 3. Choose modules, functions and attributes from an allowlist dictionary, not from `getattr()` or `import_module()` on a raw name. 4. Keep interactive consoles and debuggers off network ports in production. 5. In Report mode, sort by the `rce` tag first: any untrusted flow into these sinks deserves a look. #### Classification {#classification} Code injection is [CWE-94](https://cwe.mitre.org/data/definitions/94.html), Improper Control of Generation of Code; `eval()` and `exec()` on untrusted input are its child [CWE-95](https://cwe.mitre.org/data/definitions/95.html), Eval Injection, and picking code by an untrusted name is [CWE-470](https://cwe.mitre.org/data/definitions/470.html), Unsafe Reflection. OWASP maps all three to [A05:2025 Injection](https://top10.owasp.org/2025/A05_2025-Injection/). #### Questions people ask {#faq} The questions that come up on forums and in reviews about this class, each answered in a few sentences. ##### Is eval on user input ever safe? No: `eval()` runs whatever expression it is given with the program's full privileges, and filtering the string first has been bypassed every time it has been tried. For arithmetic or a configuration value, parse the input with `ast.literal_eval()` or a small parser of your own, and map user choices to functions with a dictionary rather than `getattr()` or a computed import. es-python reports `eval()`, `exec()` and `compile()` on untrusted input as Critical. ##### Is ast.literal_eval a safe replacement for eval? It is safe in the sense that matters: `ast.literal_eval()` accepts only literals (strings, numbers, tuples, lists, dicts, sets, booleans and None) and cannot call a function or run code. It still parses the input, so a very large or deeply nested string costs memory and time, and es-python reports it as a `compile` event on untrusted data so the call site stays visible. If you rely on it deliberately, record that decision with a rule for that line. ##### How does es-python detect code injection? It reports untrusted data reaching the operation that turns it into code, `eval`, `exec`, `compile`, `__import__` and the interactive console, at the moment the program performs it, with the value, its source and the stack trace. The event carries the `rce` impact tag and is Critical when the data came from the network. Under a Raise rule the call raises a Python RuntimeError before anything is compiled, so the injected code never runs. ### Server-side template injection (SSTI) in Python URL: https://eyalsec.com/vulnerabilities/server-side-template-injection | CWE-1336, CWE-94 | OWASP A05:2025 Injection Server-side template injection is a flaw where data an attacker controls becomes part of a template's source instead of its data, so the template engine evaluates the attacker's expressions on the server. #### How it shows up in Python {#in-python} Template engines draw a hard line between the template, which is code, and the context, which is data. SSTI is crossing that line, and in Flask it usually takes one f-string: ```python from flask import request, render_template_string @app.get("/hello") def hello(): name = request.args.get("name", "there") return render_template_string(f"

Hello {name}

") ``` The visitor's name is now template code. A name of `{{ 7*7 }}` renders `49`, which is the usual way the bug is confirmed. From there, an expression can walk from any object in the template's reach to the interpreter's internals, and in Jinja2 that ends in running arbitrary code on the server. Mako and Tornado templates go further still: their expression syntax is plain Python, so there is nothing to escape from. The fix is to keep the template constant and pass the value as context. The engine then treats it as data and escapes it for HTML: ```python from flask import request, render_template_string @app.get("/hello") def hello(): name = request.args.get("name", "there") return render_template_string("

Hello {{ name }}

", name=name) ``` The same rule holds for every engine. `jinja2.Template(text)`, `Environment.from_string(text)`, `django.template.Template(text)`, `mako.template.Template(text)` and `tornado.template.Template(text)` all compile their argument as code, so none of them should ever see request data. Django's template language is more restricted than Jinja2's, but an injected Django template can still read whatever objects its context exposes. Some products do need user-authored templates: a SaaS that lets customers edit notification emails, for example. For those: ```python from string import Template def render_notification(user_template: str, values: dict) -> str: return Template(user_template).safe_substitute(values) ``` `string.Template` only substitutes `$name` placeholders, with no attribute access and no expressions. If customers need loops and conditions, use Jinja2's `SandboxedEnvironment`, expose plain data rather than application objects, and treat the sandbox as a reduction of risk rather than its removal. #### Why it is hard to see {#missed} - **Template text that arrives later.** The template is stored in the database (an editable email or page fragment) and rendered by a background job. The request that planted it and the code that renders it are in different places, and neither looks dangerous alone. - **Blind rendering.** When the rendered result is emailed or written to a file instead of returned in the response, a dynamic scanner sends its probe and sees no difference in the reply. - **Assembled across functions.** One helper builds a fragment, another wraps it, a third renders it. Rules that look for a non-constant argument at the render call lose the thread across the helpers. - **Not HTTP at all.** Template text read from a queue message, an uploaded archive or a partner API never passes a web application firewall. #### What the es-python event looks like {#event} es-python reports untrusted data reaching the call that turns text into a template: `ssti jinja2` for `Environment.from_string` (which `jinja2.Template` and Flask's `render_template_string` both use), `ssti django`, `ssti mako` and `ssti tornado` for those engines' `Template` constructors. All four carry the `ssti` [impact tag](https://eyalsec.com/docs/events#impact-tags), and with data from the network the event is graded Critical. Because the check is on the template text, a template rendered with untrusted values in its context, the fixed version above, produces no SSTI event. The event fires whether or not the input contained template syntax, because the flaw is the flow, not this particular value. When EyalSec can prove the untrusted text carried nothing the engine would read as syntax, the event is [suppressed](https://eyalsec.com/docs/suppressed-events): stored, but out of the default view. User-authored templates passed to a `SandboxedEnvironment` are still reported, since the sandbox limits damage but the attacker still writes template code; if that is a product feature, record it with a [rule](https://eyalsec.com/docs/rules) for that call site. Python code that reads an attribute whose name came from outside, `getattr(obj, name)`, is reported as `getattr` and carries the same `ssti` tag, because attribute walks are how injected templates reach code. Most such calls are ordinary serializers reading a field name, so EyalSec suppresses every `getattr` event whose name is not one of the interpreter's introspection attributes, and keeps the rest. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise), the program gets a `RuntimeError` at that call instead of a rendered page. #### How to fix and prevent it {#fix} 1. Keep request data out of template source: store templates in files or constants and pass values as context. 2. Search for `render_template_string`, `from_string` and direct `Template(` constructors, and check where their first argument comes from. 3. For user-authored templates, prefer `string.Template`; if you need logic, use a sandboxed environment with plain data only. 4. Give templates the values they display, not application objects (models, the request, settings). 5. Keep autoescaping on. It does not stop SSTI, but it stops the same flaw from also being cross-site scripting. #### Classification {#classification} SSTI is [CWE-1336](https://cwe.mitre.org/data/definitions/1336.html), Improper Neutralization of Special Elements Used in a Template Engine, a child of [CWE-94](https://cwe.mitre.org/data/definitions/94.html), Code Injection. CWE-1336 is not itself on the OWASP mapping list for 2025, but its parent CWE-94 is, under [A05:2025 Injection](https://top10.owasp.org/2025/A05_2025-Injection/). #### Questions people ask {#faq} The questions that come up on forums and in reviews about this class, each answered in a few sentences. ##### Is Jinja2 safe from template injection? Jinja2 is safe when the template is yours and the user's data is passed in the context: `render_template("page.html", name=name)` autoescapes the value and never evaluates it. The flaw appears when user input becomes part of the template text itself, through `render_template_string()` on a formatted string or `Template(user_text)`, because the engine then evaluates the attacker's expressions on the server. Jinja2's `SandboxedEnvironment` limits the damage but still runs attacker-written template code. ##### What is the difference between XSS and SSTI? XSS runs attacker script in another user's browser, after the page is delivered; SSTI runs attacker expressions on the server, while the page is being built, and from there can usually reach Python objects and execute code. Autoescaping prevents XSS by escaping values in the output and does nothing for SSTI, because the injected text is never a value. Tell them apart by where the input lands: the context or the template. ##### How does es-python detect SSTI? It reports untrusted data reaching the call that turns text into a template, `Environment.from_string` for Jinja2 and the `Template` constructors of Django, Mako and Tornado, with the `ssti` impact tag and a Critical grade when the data came from the network. A template rendered with untrusted values in its context produces no event, because the check is on the template text. When the text provably carried nothing the engine reads as syntax the event is suppressed, and a Raise rule raises a Python RuntimeError instead of rendering. ### Insecure deserialization in Python URL: https://eyalsec.com/vulnerabilities/insecure-deserialization | CWE-502 | OWASP A08:2025 Software or Data Integrity Failures Insecure deserialization is a flaw where a program rebuilds Python objects from bytes an attacker controls, and the rebuilding itself runs attacker-chosen code, before the program ever looks at the result. #### How it shows up in Python {#in-python} `pickle` is the common case, because it is convenient and because its danger is not obvious. Loading a pickle is not parsing data: the format can name a callable and the arguments to call it with, and `pickle.loads()` makes that call while rebuilding the object. So any value the program unpickles can run code, and this cookie-backed session store is remote code execution: ```python import base64 import pickle from flask import request def load_session(): raw = base64.b64decode(request.cookies["session"]) return pickle.loads(raw) ``` The attacker controls the cookie, so the attacker controls what gets rebuilt, and a malicious pickle runs its payload the moment `loads()` is called. Nothing downstream can save it, because the damage is done during the load. The fix is a format that only carries data. For a session, use a signed token so a value the server did not issue is rejected before it is parsed: ```python import json from itsdangerous import URLSafeSerializer, BadSignature from flask import request, abort serializer = URLSafeSerializer(app.secret_key, salt="session") def load_session(): try: return serializer.loads(request.cookies["session"]) except (BadSignature, KeyError): abort(400) ``` `itsdangerous` serializes with JSON and signs the result, so untrusted bytes are rejected at the signature check and never deserialized into live objects. The same rule applies to the near neighbours: - **PyYAML.** `yaml.load(data)` with the plain `Loader`, or `yaml.unsafe_load(data)`, can construct arbitrary Python objects, exactly like pickle. Use `yaml.safe_load(data)`, which builds only standard scalars, lists and dicts. - **jsonpickle.** `jsonpickle.decode()` reconstructs arbitrary classes from JSON, so it is unsafe on untrusted input even though the wire format looks like plain JSON. Use `json.loads()` when the data is data. - **`json` with hooks.** `json.loads()` alone is safe. `json.loads(data, object_hook=...)` runs your hook on every object, so an `object_hook` that instantiates classes by name reintroduces the problem. - **marshal.** `marshal.loads()` is for trusted `.pyc` data and makes no safety guarantee on untrusted bytes. #### Why it is hard to see {#missed} - **First-party, so no advisory.** The dangerous call is `pickle.loads()` in your own code. A dependency scanner only knows published CVEs in packages, so a first-party deserialization bug maps to nothing it tracks. - **The gadget materializes at load time.** The payload is data, not source, and it only becomes a call during `loads()`. A static analyzer reading the source sees a load, not the code the load will run. - **The behaviour is a normal function call.** Endpoint and behavioural tools key on what a process does after the fact. Code reached through deserialization runs as an ordinary call inside the interpreter, with far less for them to key on, and es-python can stop it before the call happens. - **Serialized state between internal services.** Two back-end services exchange pickled objects over a queue with no HTTP request in sight, so request-scoped tools never inspect it. #### What the es-python event looks like {#event} es-python reports untrusted data reaching a deserializer, at the call: `pickle loads` and `pickle load`, `marshal loads`, `yaml unsafe load` for an unsafe PyYAML loader, `object hook` for `json.loads()` with a hook set, and `unsafe deserialization` for `jsonpickle`. The pickle and marshal sinks carry the `rce` [impact tag](https://eyalsec.com/docs/events#impact-tags); the others carry `unsafe deserialization`. `yaml.safe_load()` and a plain `json.loads()` are not instrumented as deserialization sinks, so correct code is silent. Deserialization is the one class here EyalSec never [suppresses](https://eyalsec.com/docs/suppressed-events) on the value. For an injection it can sometimes prove the untrusted characters could not break out of the statement; a serialized blob has no such safe form, because any byte can be part of the instruction that runs code. So an `unsafe deserialization` event always means attacker-controlled bytes reached a loader that can execute them. With network data, it is graded Critical. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise), `pickle.loads()` raises a `RuntimeError` before the bytes are processed, so the embedded payload does not run. #### How to fix and prevent it {#fix} 1. Keep pickle away from client data, cookies, caches you do not fully trust, and files an attacker could write. Treat pickle as a format between code you control on both ends. 2. Use data formats for data: `json` for structured values, `yaml.safe_load()` for YAML, a schema library such as Pydantic to validate the result. 3. For signed state like sessions, use a signed serializer (`itsdangerous`, a signed JWT) so forged data is rejected before it is parsed. 4. Keep any `object_hook` or `object_pairs_hook` from instantiating classes by name from untrusted input. 5. In Report mode, treat any deserializer event on a network or file source as a priority: unlike most classes, there is no benign version of this flow. #### Classification {#classification} Insecure deserialization is [CWE-502](https://cwe.mitre.org/data/definitions/502.html), Deserialization of Untrusted Data. OWASP maps CWE-502 to [A08:2025 Software or Data Integrity Failures](https://top10.owasp.org/2025/A08_2025-Software_or_Data_Integrity_Failures/) in the Top 10 for 2025. #### Questions people ask {#faq} The questions that come up on forums and in reviews about this class, each answered in a few sentences. ##### Is pickle safe if I sign the data? Signing protects the bytes in transit, so a pickle you serialized yourself and verify with an HMAC before loading cannot be swapped by an attacker who lacks the key. It does nothing once the key leaks, nothing for a service that signs attacker-supplied objects, and nothing if any code path loads an unsigned copy. Python's own documentation says to unpickle only data you trust, and a signature is one way to establish that trust, not a replacement for a data-only format. ##### Which Python formats are safe to load from untrusted input? Formats that carry data and never instructions: `json.loads()` without an object hook, `yaml.safe_load()`, MessagePack, CBOR and Protocol Buffers rebuild values, not arbitrary objects. `pickle`, `marshal`, `shelve`, `yaml.load()` with an unsafe loader and `jsonpickle` can all run code while loading. For machine-learning models, prefer a weights-only format such as safetensors over a pickled model file. ##### How do I find every pickle.loads in a running app? Run the application with es-python and switch on the sources that feed it, such as the network and files, and every call to `pickle.loads()`, `pickle.load()`, `marshal.loads()` or an unsafe YAML loader that receives untrusted bytes is reported with the stack trace, including calls hidden inside a cache backend, a task queue or a session store. Deserialization events are never suppressed, because no byte sequence can be proven harmless. A Raise rule stops the load before the bytes are processed. ### Path traversal in Python URL: https://eyalsec.com/vulnerabilities/path-traversal | CWE-22 | OWASP A01:2025 Broken Access Control Path traversal is a flaw where data an attacker controls decides which file the program opens, letting them escape the directory the program meant to stay in and read or write files elsewhere. #### How it shows up in Python {#in-python} The usual shape joins a base directory to a name from the request and opens it: ```python import os from flask import request DOCS = "/srv/app/user-docs" @app.get("/download") def download(): name = request.args["file"] with open(os.path.join(DOCS, name), "rb") as f: return f.read() ``` `os.path.join` looks protective but is not: a `file` that climbs out of the directory with `..` segments resolves to a path outside `DOCS`, and an absolute path passed as `name` discards the base entirely, because `os.path.join("/srv/app", "/etc/passwd")` is `/etc/passwd`. The attacker reads files the web user can read. The fix is to resolve the final path and confirm it is still inside the base directory, rather than trying to filter the input: ```python from pathlib import Path from flask import request, abort DOCS = Path("/srv/app/user-docs").resolve() @app.get("/download") def download(): name = request.args["file"] target = (DOCS / name).resolve() if not target.is_relative_to(DOCS) or not target.is_file(): abort(404) return target.read_bytes() ``` `resolve()` collapses `..` and symlinks, and `is_relative_to()` (Python 3.9+) rejects anything that landed outside. When the set of files is known, the sturdier option is to not take a path at all: accept an identifier and look the real path up in a dictionary or a database row. The same traversal applies to writes (`open(path, "w")`, `os.makedirs`), and writing outside the directory is usually worse than reading. Archive extraction is path traversal with the attacker supplying the names. A crafted entry name in a zip or tar can carry `..` segments or an absolute path, so extracting it writes outside the target directory. On Python 3.12+ pass `filter="data"` to `TarFile.extractall`; otherwise validate each resolved member path against the destination before writing it, the same check as above. #### Why it is hard to see {#missed} - **Second-order.** The name is stored in one request and used to build a path in another, or in a later job. No single handler shows input flowing to `open()`. - **Decoded or rebuilt in the app.** The traversal is URL-encoded, or assembled from two fields, or pulled out of a parsed document, so there is no `..` in any single request field for a signature to catch. - **No response signal.** A write that traverses, or a read whose result is emailed rather than returned, gives a black-box scanner no difference in the reply to detect. - **Archive entries.** The malicious name is inside an uploaded zip or tar, not in the request body at all. #### What the es-python event looks like {#event} es-python reports when an untrusted path reaches a file operation. Two things fire together. The base event names the operation: `io open` for `open()`, and `os stat`, `os open`, `os listdir`, `os mkdir`, `os unlink` and their siblings for the `os` calls. Alongside it, a `path traversal` event fires when the untrusted path contains a `..` segment or is absolute, and that is the one tagged `path traversal`. A `null byte injection` event marks an embedded NUL, which is always deliberate. Archive extraction has its own labels, `zip slip` and `tar slip`, checked against the raw member name before any sanitising runs. A plain `io open` on untrusted input, with no traversal in the value, still produces an event, because reading an attacker-named file in the intended directory can still be wrong; it carries the `path traversal` tag at a lower grade. The matching is byte-level and POSIX-only: it looks for `/` separators and does not decode percent-encoding, so a value the application URL-decodes itself is covered only after that decode happens in Python. The [severity](https://eyalsec.com/docs/events#severity) follows the source: network data reaching a path is graded high, and a path from a file only the owner can write is Info. A write event is graded differently: it is a disclosure signal, so es-python records where the data was written and whether other users can read that file, and grades it from that. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise), the operation raises a `RuntimeError` before the file is opened. #### How to fix and prevent it {#fix} 1. Resolve the final path with `Path.resolve()` and confirm it is inside the intended directory with `is_relative_to()`. Skip stripping `..` by hand. 2. Prefer an identifier mapped to a known path over accepting a path at all. 3. Reject absolute paths and embedded NUL bytes outright. 4. Extract archives with `filter="data"` on 3.12+, or validate each member's resolved path on older versions. 5. Run the application as a user that cannot read or write anything outside what it needs, so a traversal that slips through reaches less. #### Classification {#classification} Path traversal is [CWE-22](https://cwe.mitre.org/data/definitions/22.html), Improper Limitation of a Pathname to a Restricted Directory. OWASP maps CWE-22 to [A01:2025 Broken Access Control](https://top10.owasp.org/2025/A01_2025-Broken_Access_Control/) in the Top 10 for 2025. #### Questions people ask {#faq} The questions that come up on forums and in reviews about this class, each answered in a few sentences. ##### Does os.path.join prevent path traversal? No: `os.path.join()` joins text, so a component that is absolute replaces everything before it, and a `..` segment is kept and walks out of the directory when the path is opened. Resolve the joined path with `os.path.realpath()` and check that the result still starts with the intended base directory, or map user choices to file names you wrote. es-python reports an untrusted path that contains `..` or is absolute as a `path traversal` event. ##### How do I safely build a file path from user input in Python? Treat the user's value as a key, not a path: look it up in a dictionary of allowed names, or restrict it to a single safe component with no separators and no `..`. If the value must be a path, resolve it with `pathlib.Path.resolve()` and refuse it unless it is inside the base directory after resolution. Keep the check on the resolved path, because a check on the raw string is what traversal bypasses. ##### How does es-python report path traversal? It reports at the file operation, when an untrusted path reaches `open()` or an `os` call, with the value, its source and the stack trace. A `path traversal` event fires alongside when the path contains a `..` segment or is absolute, `zip slip` and `tar slip` mark archive members that escape, and `null byte injection` marks an embedded NUL. Network data reaching a path is graded High, and a Raise rule stops the operation before the file is opened. ### Server-side request forgery (SSRF) in Python URL: https://eyalsec.com/vulnerabilities/ssrf | CWE-918 | OWASP A01:2025 Broken Access Control Server-side request forgery is a flaw where an attacker makes the server itself send a request to a destination of their choosing, reaching internal services and cloud metadata that the attacker could never reach directly. #### How it shows up in Python {#in-python} Any feature that fetches a URL the user supplies is a candidate: a webhook, an "import from URL", a link preview, an avatar fetched from a profile address. The code looks harmless: ```python import requests from flask import request @app.post("/preview") def preview(): url = request.form["url"] resp = requests.get(url, timeout=5) return {"title": extract_title(resp.text)} ``` The server will fetch whatever `url` says, including `http://169.254.169.254/...` (the cloud metadata service, which can hand out credentials), `http://localhost:6379/` (an internal Redis), or a link-local or private-range address that only the server can reach. From the outside an attacker cannot touch those; through this endpoint they can. SSRF is hard to fix by inspecting the string, because a hostname can resolve to an internal address, a redirect can send a safe-looking URL to an internal one, and there are many spellings of the same address. The reliable defences are structural: ```python import ipaddress import socket from urllib.parse import urlparse import requests from flask import request, abort def safe_target(url: str) -> str: parts = urlparse(url) if parts.scheme not in ("http", "https") or not parts.hostname: abort(400) for info in socket.getaddrinfo(parts.hostname, parts.port or 80): ip = ipaddress.ip_address(info[4][0]) if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: abort(400) return url @app.post("/preview") def preview(): url = safe_target(request.form["url"]) resp = requests.get(url, timeout=5, allow_redirects=False) return {"title": extract_title(resp.text)} ``` Resolve the host and reject private, loopback, link-local and reserved addresses; allow only `http` and `https`; and disable redirects (or re-validate each hop), because a permitted host can redirect to a forbidden one. Stronger still is to route outbound fetches through an egress proxy that enforces an allowlist, so the policy lives in one place instead of every call site. #### Why it is hard to see {#missed} - **The request never leaves through the edge.** SSRF is the server making an internal, east-west call. A web application firewall and a CDN watch inbound traffic to the origin, not the origin's own outbound requests. - **A hostname that resolves inward.** The URL contains an ordinary public-looking hostname whose DNS record points at an internal address, so there is nothing in the string to match. - **Redirect to an internal target.** The submitted URL is on an allowed host and returns a redirect to `169.254.169.254` or `localhost`. A check on the original string passes. - **Assembled or decoded in the app.** The destination is built from a base and a user-supplied path, or decoded from an encoded parameter, so no single field is a URL. #### What the es-python event looks like {#event} es-python reports at the point the connection is actually made, which is the one place every one of those bypasses converges. The base event is `socket connect`, `socket getaddrinfo` or `socket gethostbyname` (the call `requests`, `urllib` and friends all funnel through); when the destination the untrusted value resolves to is a loopback, link-local, or private-range address, an `ssrf` event fires alongside it, tagged `ssrf`. Because this is decided on the address the program is dialling, an internal hostname and an in-application redirect both still fire: the final destination is what is checked, not the submitted string. The honest limits are worth stating. The address test recognises the standard dotted-quad and canonical IPv6 spellings, not alternate encodings of the same address, and a DNS name that resolves internally through the system resolver is caught only when the program resolves it explicitly through Python; there is no DNS-rebinding coverage. So the `ssrf` label is a strong signal when it fires, and the base `socket connect` event on untrusted input is the backstop that shows every outbound destination the request steered. The [severity](https://eyalsec.com/docs/events#severity) follows the source. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise), the connection raises a `RuntimeError` before it is made. #### How to fix and prevent it {#fix} 1. Resolve the host and reject private, loopback, link-local and reserved IP addresses, for every address a hostname resolves to. 2. Allow only `http` and `https`, and an allowlist of hosts where you can. 3. Disable redirects or re-validate every hop; the first URL is only the start. 4. Route outbound fetches through an egress proxy that enforces the allowlist in one place. 5. Lock down the metadata endpoint (require IMDSv2 on AWS) so a fetch that does slip through gets nothing useful. 6. In Report mode, watch the `ssrf` tag and the `socket connect` events on request data to see every destination user input can steer. #### Classification {#classification} SSRF is [CWE-918](https://cwe.mitre.org/data/definitions/918.html), Server-Side Request Forgery. In the Top 10 for 2025 OWASP folded SSRF into [A01:2025 Broken Access Control](https://top10.owasp.org/2025/A01_2025-Broken_Access_Control/), which lists CWE-918 among its mapped weaknesses. #### Questions people ask {#faq} The questions that come up on forums and in reviews about this class, each answered in a few sentences. ##### Why is SSRF dangerous in a cloud environment? Because the server can reach what the attacker cannot: internal services, admin interfaces and the cloud metadata endpoint, which hands out instance credentials to any process on the machine. One forged request from a service that fetches user-supplied URLs can turn into cloud credentials. The fix is to decide the destination from an allowlist and to check the resolved address, not the submitted string. ##### Does validating the URL hostname stop SSRF? Only partly: a hostname check on the submitted string is bypassed by a DNS name that resolves to an internal address, by an HTTP redirect to one, and by alternate spellings of the same IP address. Check the address the program is about to connect to, after resolution and after each redirect, and disable redirects where you can. es-python makes the same decision at the connection, on the final destination, which is why an internal hostname and a redirect both still produce an `ssrf` event. ##### How does es-python detect SSRF? It reports at the point the connection is made, `socket connect`, `socket getaddrinfo` or `socket gethostbyname`, when the destination was chosen by untrusted data, and adds an `ssrf` event when that destination is a loopback, link-local or private-range address. The check is on the address the program is dialling, so an internal hostname and an in-application redirect both fire. It recognises the standard spellings of an address, not alternate encodings, and a Raise rule stops the connection before it is made. ### NoSQL injection in Python URL: https://eyalsec.com/vulnerabilities/nosql-injection | CWE-943 | OWASP A05:2025 Injection NoSQL injection is a flaw where data an attacker controls changes the structure of a database query rather than supplying a value to it, most often by smuggling query operators into a document store like MongoDB. #### How it shows up in Python {#in-python} MongoDB does not parse a string query, so classic SQL-style injection does not apply, but a different door is open. A filter is a dictionary, and if a request value is dropped into it unchanged, the attacker can replace a plain value with an operator document: ```python from flask import request def find_user(db, body): return db.users.find_one({"username": body["username"], "password": body["password"]}) ``` If the request body is JSON and the client sends `{"$ne": null}` where a password string was expected, the filter becomes "password not equal to null", which matches a record regardless of the real password. The fix is to force each value to the type you expect before it reaches the query, so an operator document can never take a value's place: ```python from flask import request, abort def find_user(db, body): username = body.get("username") password = body.get("password") if not isinstance(username, str) or not isinstance(password, str): abort(400) return db.users.find_one({"username": username, "password": password}) ``` A schema library such as Pydantic does the same at the edge and gives you typed fields everywhere after. Two deeper cases need care: a `$where` clause or a `mapReduce` runs a JavaScript expression inside the database, so a tainted value there is code injection, not just operator injection, so keep user input out of it; and Redis is safe for keys and values (its protocol frames them by length) but `EVAL` runs a Lua script, so a script body built from user input is injection into that script. #### Why it is hard to see {#missed} - **The payload is structure, not characters.** `{"$ne": null}` contains no SQL metacharacters and no shell syntax, so signature rules written for those find nothing to flag. - **It arrives as ordinary JSON.** The attack is a well-formed JSON object; a web application firewall sees a valid body. - **The operator is added in the application.** The request sends a value and the code wraps it in a filter dict, so the dangerous shape only exists at the query call. - **No request at all.** A worker builds a MongoDB filter from a queue message or a stored document. #### What the es-python event looks like {#event} es-python reports when untrusted data reaches a query call on a supported driver. For PyMongo it covers the `Collection` methods that take a filter or a pipeline (`find`, `find_one`, `count_documents`, the `delete_*`, `update_*` and `find_one_and_*` methods, and `aggregate`), with the label `nosql mongodb`. For Redis it covers `eval` and `eval_ro`, the Lua entry points, with the label `nosql redis`. Both carry the `nosql injection` [impact tag](https://eyalsec.com/docs/events#impact-tags). For MongoDB the report narrows to the parts that matter: the `$`-prefixed operator keys in the filter, and the value of the code-bearing operators such as `$where`, `$expr` and `$function`. Redis is in the list only for `EVAL`, and that is deliberate: a tainted key or value cannot escape its length-prefixed RESP field, so storing user data in Redis is the normal case and does not fire. A tainted Lua script body is genuine code injection into the database, and that is what the `nosql redis` label marks. With network data the event is graded high. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise), the query raises a `RuntimeError` before it runs. #### How to fix and prevent it {#fix} 1. Coerce every request value to its expected type before it reaches a query, so an operator document cannot stand in for a value. Validate with a schema library at the edge. 2. Keep user input out of every `$where`, `$expr`, `$function` or `mapReduce` body and Redis Lua script. 3. Keep operator keys out of user-controlled positions: a filter's keys should come from your code, not from the request. 4. Give the database account the least privilege it needs. 5. In Report mode, watch the `nosql injection` tag to see which queries receive request data directly. #### Classification {#classification} NoSQL injection is [CWE-943](https://cwe.mitre.org/data/definitions/943.html), Improper Neutralization of Special Elements in Data Query Logic, a child of [CWE-74](https://cwe.mitre.org/data/definitions/74.html), Injection. CWE-943 is not individually listed in the OWASP 2025 mapping, but its parent CWE-74 sits under [A05:2025 Injection](https://top10.owasp.org/2025/A05_2025-Injection/), which is where this class belongs. #### Questions people ask {#faq} The questions that come up on forums and in reviews about this class, each answered in a few sentences. ##### Is MongoDB vulnerable to injection from Python? Yes, in a different shape from SQL: the risk is not a string that becomes query text but a value that becomes a query operator, such as a request parameter that arrives as a dictionary with a `$ne` key instead of a string and matches every document. PyMongo passes a dictionary through as written, so the fix is to check the type of each value before it goes into a filter and to reject keys that start with `$`. `$where` and the other code-bearing operators take JavaScript and deserve the same care as `eval()`. ##### How do I prevent NoSQL injection in PyMongo? Validate the type of every value that reaches a filter, so a string field cannot arrive as a dictionary of operators, and build filters from fields you name rather than from a request body passed through whole. Avoid `$where`, `$expr` with user input and `$function`, which evaluate code on the server. es-python reports the `$`-prefixed operator keys and the code-bearing operator values that came from untrusted data, so the shapes that matter are the ones you see. ##### How does es-python report NoSQL injection? It reports when untrusted data reaches a PyMongo query method, `find`, `update_one`, `aggregate` and the rest, or a Redis `EVAL`, with the label `nosql mongodb` or `nosql redis` and the `nosql injection` impact tag. For MongoDB the report narrows to the operator keys and the code-bearing operator values, because a tainted plain value is the normal case. With network data the event is graded High, and a Raise rule stops the query before it runs. ### Untrusted code loading in Python URL: https://eyalsec.com/vulnerabilities/untrusted-code-loading | CWE-829, CWE-427 | OWASP A08:2025 Software or Data Integrity Failures Untrusted code loading is a flaw where the program imports or runs a Python file that someone other than the owner could have written, so whoever can edit that file runs code inside the program. #### How it shows up in Python {#in-python} This class is not about a value reaching a dangerous call; it is about which file the interpreter runs. Python runs the code in a module the moment it is imported, so a file that an attacker can write is code the attacker can run. The usual ways it happens: - **A world-writable or group-writable directory on the import path.** A plugin folder, a shared `/opt` tree, or a directory on `PYTHONPATH` that another local user or service can write to. Dropping a file there, or editing one, injects code into every program that imports from it. - **The current directory on the path.** A tool run from a directory an attacker can write to may import a file they planted there, because the working directory sits early on the import path. - **Shadowing a standard-library name.** A file called `json.py` or `logging.py` next to the program, or installed over a standard-library name, is imported instead of the real module. `import json` then runs the attacker's file. - **A plugin system that loads files from a writable location.** `importlib`, `runpy`, or `compile()`/`exec()` reading a file whose contents an attacker could change. The defence is ownership and permissions, not input filtering. Keep every directory on the import path owned by the deploying user and writable by no one else; keep world-writable directories off `PYTHONPATH`; run production tools from a directory only you can write to; and load plugins only from a location your deploy controls, ideally verifying them against a manifest you shipped. Running the application as an unprivileged user with a read-only code tree removes most of the opportunity. #### Why it is hard to see {#missed} - **The source file is clean.** The vulnerability is the permissions on a directory, not a line of code, so a static analyzer reading the source has nothing to flag. - **It happens during startup.** The malicious import often runs while the program is still initialising, before any request is handled, so request-scoped tooling never engages. - **It is a legitimate import.** `import json` is correct code; only the file it resolves to is wrong. Nothing about the call is suspicious on its own. - **No network involved.** This is a local attacker or a compromised neighbouring account, so an edge filter sees nothing. #### What the es-python event looks like {#event} With the [foreign source](https://eyalsec.com/docs/taint-sources#foreign) on, es-python reports when the program loads code from a file another user on the machine can write. The event names the file: `foreign-code:` followed by the path, once per file per process so an import loop does not flood the list, and it carries the `untrusted code load` [impact tag](https://eyalsec.com/docs/events#impact-tags). The load operation is reported as `run script`, `run pyc`, `import (foreign code)` or `compile`, depending on how the file was reached. You can trust specific locations on purpose, such as a shared tools directory your team maintains, by listing their path prefixes in the agent's foreign-code allowlist; nothing is exempt automatically. A second check, `stdlib shadowing`, fires when a module whose name belongs to the standard library is loaded from a file outside the standard-library tree, so `import json` running someone else's `json.py` is reported even when that file is one you own. Because loading foreign code is exactly what the agent exists to stop, a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) here raises a `RuntimeError` and blocks the load, even during startup imports, rather than letting it run and only recording it. #### How to fix and prevent it {#fix} 1. Make every directory on the import path, including the working directory for production tools, writable only by the deploying user. 2. Keep the current directory and any world-writable directory off `PYTHONPATH`. 3. Load plugins only from a location your deploy controls, and verify them against a manifest where you can. 4. Give local modules names the standard library does not use; the shadow check points out a clash. 5. Run the application as an unprivileged user with a read-only code tree. 6. Turn on the foreign source in Report mode to see which files the program loads that another user could write, then raise on it once the list is clean. #### Classification {#classification} Loading foreign-writable code is [CWE-829](https://cwe.mitre.org/data/definitions/829.html), Inclusion of Functionality from Untrusted Control Sphere; shadowing a module via the import path is [CWE-427](https://cwe.mitre.org/data/definitions/427.html), Uncontrolled Search Path Element. OWASP maps both to [A08:2025 Software or Data Integrity Failures](https://top10.owasp.org/2025/A08_2025-Software_or_Data_Integrity_Failures/) in the Top 10 for 2025. #### Questions people ask {#faq} The questions that come up on forums and in reviews about this class, each answered in a few sentences. ##### What counts as untrusted code loading? Importing or running a Python file that someone other than the program's owner could have written: a module in a directory another user can write to, a script fetched from a shared location, or a plugin folder with loose permissions. Whoever can edit that file runs code inside your program with its privileges. The flaw is in where the code comes from, not in what it contains today. ##### Is importing from a user-writable directory really a vulnerability? Yes, because an import executes the module's top level, so a writable directory early on `sys.path` lets any local user run code in your process the next time it starts, and a file named like a standard-library module shadows the real one. Shared hosts, CI runners and containers that mount a host directory are where this happens in practice. Fix the permissions, keep `sys.path` to directories only the owner can write, and keep the working directory off the path. ##### How does es-python report untrusted code loading? With the foreign source on, it reports a load from a file another user can write, named `foreign-code:` plus the path, once per file per process, with the `untrusted code load` impact tag and the operation (`run script`, `run pyc`, `import (foreign code)` or `compile`). A separate `stdlib shadowing` check fires when a standard-library module name is loaded from outside the standard library. A Raise rule blocks the load even during startup imports, and an allowlist of path prefixes trusts the locations you choose. ### Regular expression denial of service (ReDoS) in Python URL: https://eyalsec.com/vulnerabilities/redos | CWE-1333 ReDoS is a flaw where a regular expression takes time that grows explosively on certain inputs, so an attacker can hang a request, or a whole worker, with a short string. #### How it shows up in Python {#in-python} Python's `re` module backtracks, so a pattern with nested or overlapping repetition can be driven into exponential work. A validator is the classic home for it, because validators run on untrusted input by design: ```python import re from flask import request, abort EMAIL = re.compile(r"^([a-zA-Z0-9]+)+@example\.com$") @app.post("/subscribe") def subscribe(): address = request.form["email"] if not EMAIL.fullmatch(address): abort(400) subscribe_address(address) return "ok" ``` `([a-zA-Z0-9]+)+` has two nested quantifiers over the same characters, so a long run of letters that fails to match the `@` ending makes the engine try an exponential number of splits before giving up. One request with a few dozen characters can pin a CPU core for seconds. The pattern is the problem, not the input, so the fix is the pattern: remove the nesting so each character is consumed one way only. ```python import re from flask import request, abort EMAIL = re.compile(r"^[a-zA-Z0-9]+@example\.com$") @app.post("/subscribe") def subscribe(): address = request.form["email"] if not EMAIL.fullmatch(address): abort(400) subscribe_address(address) return "ok" ``` For inputs you cannot bound, a linear-time engine such as `google-re2` removes the risk by construction. There is a second, sharper shape: a pattern built from user input, `re.compile(user_value)`, or passed to `re.search(user_value, text)`. Now the attacker supplies the pattern itself, which is both a denial-of-service lever and, if the pattern is being used as a filter, a way to craft one that quietly accepts input the check was meant to reject. Compile only patterns you wrote; if users need matching, give them literal substring search or a tiny grammar you control. #### Why it is hard to see {#missed} - **It is not a crash.** A coverage-guided fuzzer looks for crashes and new paths; a ReDoS input returns a correct answer, just slowly, so there is nothing for the fuzzer to flag. - **It depends on the input class, not one value.** Whether a pattern is catastrophic depends on the shape of the input, which a static read of the pattern can only estimate. - **The pattern is far from the input.** The regex is compiled in one module and fed attacker data in another, or it lives in a dependency the request never names. - **An attacker-supplied pattern.** When the pattern comes from the request, no analysis of the source patterns applies at all. #### What the es-python event looks like {#event} es-python decides this at runtime, which is the only place the actual work is visible. The base `re match`, `re search`, `re compile` and related events fire when untrusted data reaches the engine. A separate `redos` event fires only when a match actually backtracked catastrophically over tainted input: es-python counts the engine's steps and reports when the count crosses both an absolute threshold and a steps-per-character threshold, the second of which is what separates genuine super-linear backtracking from an ordinary linear scan of a large string. So a `redos` event is evidence the work really blew up on this input, not a guess from the pattern's shape. The `redos` [impact tag](https://eyalsec.com/docs/events#impact-tags) always travels with a `regex bypass` tag, because one regex event is two possible findings the engine cannot tell apart: a pattern that hangs, and a pattern used as a filter that an attacker relaxed to slip input past a check. Both are shown, both low, and you decide which the call site is: if the regex validates or filters, read it as a bypass first. One honest limit: a pattern whose blow-up lives entirely inside a single bounded repeat is not counted, so `redos` catches the common nested-quantifier shape rather than every possible one. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) the match is aborted with a `RuntimeError`. #### How to fix and prevent it {#fix} 1. Remove nested and overlapping quantifiers from patterns that run on untrusted input; rewrite so each character is consumed one way. 2. For unbounded input, use a linear-time engine such as `google-re2`. 3. Compile only patterns you wrote. Offer literal search or a small grammar instead. 4. Bound input length before matching, as defence in depth, not as the fix. 5. In Report mode, treat a `redos` event as a measured hot spot: the engine really did blow up there. #### Classification {#classification} ReDoS is [CWE-1333](https://cwe.mitre.org/data/definitions/1333.html), Inefficient Regular Expression Complexity, a child of [CWE-407](https://cwe.mitre.org/data/definitions/407.html), Inefficient Algorithmic Complexity. Neither is mapped to a category in the OWASP Top 10 for 2025, so this class sits outside that list; it remains a real availability risk, which is why es-python reports it. #### Questions people ask {#faq} The questions that come up on forums and in reviews about this class, each answered in a few sentences. ##### What makes a regular expression vulnerable to ReDoS? A pattern that can match the same input in many ways, usually a repeated group that itself contains a repeat, such as `(a+)+` or `(\w+\s?)*`, followed by something that fails to match. The engine then tries every way of splitting the input before giving up, and the work grows exponentially with the length of the string. A pattern with a single bounded repeat and no nested quantifiers does not have this problem. ##### Does the Python re module protect against ReDoS? No: `re` is a backtracking engine with no step limit or timeout, so a vulnerable pattern on a crafted input runs until it finishes, and the request handler or worker hangs with it. Rewrite nested quantifiers, anchor the pattern, cap the input length before matching, or use the third-party `re2` binding, which guarantees linear time at the cost of some features. es-python counts the engine's steps at runtime and reports a `redos` event when a match really backtracked catastrophically. ##### How does es-python detect ReDoS? At runtime, by counting the regex engine's steps while it matches untrusted input: a `redos` event fires only when the count crosses both an absolute threshold and a steps-per-character threshold, which separates genuine super-linear backtracking from a long but linear scan. The event always carries a `regex bypass` tag too, because a pattern used as a filter may have been relaxed rather than hung, and you decide which the call site is. A Raise rule aborts the match with a Python RuntimeError. ### Hardcoded credentials in Python URL: https://eyalsec.com/vulnerabilities/hardcoded-credentials | CWE-798, CWE-259 | OWASP A07:2025 Authentication Failures Hardcoded credentials are passwords, API keys, tokens or private keys written directly into source code, where they are copied into every checkout, build and backup, and cannot be rotated without a code change. #### How it shows up in Python {#in-python} A secret in source usually starts as a shortcut meant to be temporary: ```python import stripe stripe.api_key = "sk_live_9Qk2Wd7fRt...Z" def charge(token, amount): return stripe.Charge.create(amount=amount, currency="usd", source=token) ``` The key is now in the repository history, on every developer's laptop, in CI logs and in backups. Anyone who reaches any of those has it, and revoking it means editing code and redeploying. The same applies to a database password in `settings.py`, a cloud access key in a script, a JWT signing secret as a module constant, or a private key pasted into a file. The fix is to read the secret from the environment or a secrets manager at runtime, so it lives outside the code: ```python import os import stripe stripe.api_key = os.environ["STRIPE_API_KEY"] def charge(token, amount): return stripe.Charge.create(amount=amount, currency="usd", source=token) ``` In a container or a cloud deploy, inject the value through the platform's secret store (AWS Secrets Manager, GCP Secret Manager, a Kubernetes secret, a Vault lookup) rather than an environment variable baked into an image. Keep `.env` files out of version control, and if a secret was ever committed, rotate it: removing it from the latest commit does not remove it from history or from anyone who already pulled. #### Why it is hard to see {#missed} Dedicated secret scanners exist and are worth running, but they work on text at rest and have two gaps es-python's runtime view fills: - **High-entropy strings that are not provider-shaped.** A scanner keyed on known prefixes (`sk_live_`, `AKIA`, `ghp_`) has no match for a bespoke token or internal signing key outside public patterns. - **Whether the secret is actually used, and where it goes.** A scanner reports that a string looks like a secret. It cannot say whether that value then travels into an outbound request or a log line, which is what turns a hardcoded secret into a leak. A runtime view sees the second half: the constant being read, carried through the program, and reaching a place it should not. #### What the es-python event looks like {#event} With the [hardcoded source](https://eyalsec.com/docs/taint-sources#hardcoded) on, es-python marks credential-shaped constants in your source as they are compiled, by two routes: provider-key patterns (cloud keys, tokens, PEM private-key headers, JWTs), and a name-and-shape rule for values assigned to credential-named fields that are long enough, high-entropy and not obvious placeholders. The finding does not fire where the secret is written; it fires where the marked value is **used**, so you see the leak, not just the literal. Three sink labels carry it. `credential exfiltration` is a hardcoded secret reaching the network, tagged `credential leak` and graded Critical. `credential logging` is one reaching a log record. `secret disclosure` covers a secret reaching other disclosing sinks. The source itself carries the `hardcoded secret` [impact tag](https://eyalsec.com/docs/events#impact-tags). Two honest limits: a secret whose module is already compiled to a cached `.pyc` is not re-scanned, and a passphrase-style secret with spaces in it is skipped by the shape filter on purpose. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise), the send or log raises a `RuntimeError`. #### How to fix and prevent it {#fix} 1. Read every secret from the environment or a secrets manager at runtime; keep none in source. 2. Inject secrets through the platform's secret store in production, not an environment variable baked into an image. 3. Keep `.env` and key files out of version control, and add a pre-commit secret scanner. 4. Rotate any secret that was ever committed; deleting it from the latest commit is not enough. 5. Turn on the hardcoded source and watch for `credential exfiltration` and `credential logging` to catch a secret that is not only present but actually leaving the program. #### Classification {#classification} Hardcoded credentials are [CWE-798](https://cwe.mitre.org/data/definitions/798.html), Use of Hard-coded Credentials, and the password case is [CWE-259](https://cwe.mitre.org/data/definitions/259.html), Use of Hard-coded Password. OWASP maps both to [A07:2025 Authentication Failures](https://top10.owasp.org/2025/A07_2025-Authentication_Failures/) in the Top 10 for 2025. #### Questions people ask {#faq} The questions that come up on forums and in reviews about this class, each answered in a few sentences. ##### Where should secrets live instead of source code? In the environment the program runs in, loaded at startup from a secrets manager, a vault or an environment variable set by the deployment, never in a file that is committed. A local `.env` file kept out of version control is acceptable for development as long as production never reads it. Rotate any secret that has ever been committed, because a rewritten history does not reach the clones and backups that already have it. ##### Is a secret in a private repository still a problem? Yes: a private repository is readable by every present and past collaborator, every CI runner, every laptop with a clone and every backup, and a secret copied into it cannot be rotated without a code change. Scanners that read source find the obvious patterns and are worth running in CI. es-python adds the moment a hardcoded secret is actually used, where it reaches the network, a log line or another disclosing sink. ##### How does es-python report hardcoded credentials? With the hardcoded source on, it marks credential-shaped constants as your source is compiled, by provider-key patterns and by a name-and-shape rule, and reports where the marked value is used: `credential exfiltration` when it reaches the network, `credential logging` when it reaches a log record, `secret disclosure` for other disclosing sinks. The finding carries the `hardcoded secret` impact tag. A module already compiled to a cached `.pyc` is not rescanned, and a passphrase with spaces in it is skipped on purpose. ### Weak randomness in Python URL: https://eyalsec.com/vulnerabilities/weak-randomness | CWE-330, CWE-338 | OWASP A04:2025 Cryptographic Failures Weak randomness is a flaw where a value that has to be unguessable, a session token, a password-reset link, an API key, is generated by a random source that is not meant for security, so an attacker can predict or reproduce it. #### How it shows up in Python {#in-python} Python has two random sources and they look almost the same. The `random` module is a Mersenne Twister: fast, statistically good, and fully deterministic once its internal state is known, which is recoverable from enough outputs. It is right for simulations and sampling, and wrong for anything an attacker benefits from guessing. The trap is using it for a secret: ```python import random import string def reset_token(n=12): return "".join(random.choice(string.ascii_letters + string.digits) for _ in range(n)) ``` A reset token from `random` can be predicted by an attacker who has seen other outputs, which lets them forge a link for someone else's account. The fix is the `secrets` module, which draws from the operating system's cryptographically secure generator: ```python import secrets def reset_token(n=32): return secrets.token_urlsafe(n) ``` Use `secrets.token_urlsafe`, `secrets.token_hex` or `secrets.choice` for anything security-sensitive, and make tokens long enough (32 bytes is a reasonable floor). The same trap hides in UUIDs: `uuid.uuid4()` is fine because it uses the secure generator, but `uuid.uuid1()` is built from the host and a timestamp and is partly predictable, so it should not be used as a secret. Temporary file names are a related case: a predictable name invites a race where an attacker pre-creates the path. Use `tempfile.mkstemp` or `NamedTemporaryFile`, which create the file atomically, rather than `tempfile.mktemp`, which only returns a name. #### Why it is hard to see {#missed} - **The two APIs look identical.** `random.choice` and `secrets.choice` read the same; whether a given call is a bug depends on what the value is for, which the surrounding code rarely states in a way a scanner can read. - **Generated and used far apart.** The value is produced by a helper in one module and used as a token in another. A rule that flags the `random` call cannot see that its output becomes a secret; a rule that flags the token cannot see where it came from. - **It is never an exception.** A predictable token works perfectly; nothing fails, so dynamic and behavioural tools have no event to catch until the token is abused. #### What the es-python event looks like {#event} With the [weak-random source](https://eyalsec.com/docs/taint-sources#weak-random) on, es-python marks values from the insecure generator, `random.getrandbits` (which the rest of the `random` module is built on, so `randrange`, `randint`, `choice`, `shuffle`, `sample` and `randbytes` are all covered) and `uuid1`. `uuid4` is deliberately not marked, because it uses the secure generator. Like hardcoded credentials, the finding fires where the value is **used**, not where it was generated: a `predictable token` event means a value from a non-secure source reached a place that needs to be unpredictable, and it carries the `predictable token` [impact tag](https://eyalsec.com/docs/events#impact-tags). A predictable temporary file name is reported as `predictable temp`. Because the value is used as a secret only once but may be generated far away, reporting at the point of use is what connects the two: the event's trace shows the `random` call that produced it. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise), the use raises a `RuntimeError`. This is an information-class finding rather than an injection, so it is never [suppressed](https://eyalsec.com/docs/suppressed-events) on the value: no encoding makes a predictable token unpredictable. #### How to fix and prevent it {#fix} 1. Use the `secrets` module for every value that must be unguessable: tokens, reset links, API keys, salts for one-time use. 2. Keep `random` for simulations, sampling and jitter, not for secrets. 3. Use `uuid.uuid4()` rather than `uuid1()` where a UUID stands in for a secret, and make tokens long enough. 4. Create temporary files with `mkstemp` or `NamedTemporaryFile`, not `mktemp`. 5. In Report mode, watch the `predictable token` tag to find insecure random output being used where it should not be. #### Classification {#classification} Weak randomness is [CWE-330](https://cwe.mitre.org/data/definitions/330.html), Use of Insufficiently Random Values, and the PRNG case is [CWE-338](https://cwe.mitre.org/data/definitions/338.html), Use of a Cryptographically Weak PRNG. OWASP maps both to [A04:2025 Cryptographic Failures](https://top10.owasp.org/2025/A04_2025-Cryptographic_Failures/) in the Top 10 for 2025. #### Questions people ask {#faq} The questions that come up on forums and in reviews about this class, each answered in a few sentences. ##### Is random.random safe for tokens? No: the `random` module is a Mersenne Twister, a generator built for simulations, and an attacker who sees enough of its outputs can reproduce every later one, so a session token, reset link or API key drawn from it is predictable. The same applies to `randint`, `choice`, `shuffle`, `randbytes` and to `uuid1`, which is built from the clock and the MAC address. Use the `secrets` module for anything that must be unguessable. ##### What should I use instead of random for security? The `secrets` module: `secrets.token_hex()` or `secrets.token_urlsafe()` for tokens and links, `secrets.choice()` for picking from a set, and `secrets.compare_digest()` when checking a token. `uuid.uuid4()` is also drawn from the secure generator, unlike `uuid1()`. Both are backed by the operating system's random source, so there is nothing to seed and nothing to reproduce. ##### How does es-python report weak randomness? With the weak-random source on, it marks values from `random.getrandbits()` and everything built on it, and from `uuid1()`, and reports where such a value is used as a secret: a `predictable token` event, with the `predictable token` impact tag, or `predictable temp` for a temporary file name. The trace shows the `random` call that produced the value, so the generation and the use are connected. The event is never suppressed, because no encoding makes a predictable value unpredictable. ### SQL injection in Django URL: https://eyalsec.com/vulnerabilities/sql-injection/django | Class: https://eyalsec.com/vulnerabilities/sql-injection | Framework: Django Django's ORM binds every value for you, so SQL injection in a Django project comes from the handful of APIs that accept SQL text, and from request keys that are allowed to choose column names. #### How it arises in Django {#how} A queryset built from `filter()`, `exclude()` and `get()` sends the SQL and the values separately. The escape hatches are different: each of them takes a string that becomes part of the statement. - `Manager.raw(raw_query, params=())` runs the query you give it. The Django documentation says no checking is done on the statement passed to `raw()`. - `QuerySet.extra(where=[...], select={...})` injects clauses into the generated SQL. Django documents it as an old API to use as a last resort. - `RawSQL(sql, params)` places a SQL fragment inside `annotate()`, `filter()` or `order_by()`. - `connection.cursor().execute(sql, [params])` skips the model layer entirely. All four are safe when the values travel in `params`, and all four are injectable when the values are formatted into the string. Django's documentation names two mistakes: using string formatting on the query, and putting quotes around the `%s` placeholder. The second one looks careful and is not: a quoted placeholder is filled in as text. There is a second, quieter route. Code that expands request data into keyword arguments, such as `annotate(**request.GET.dict())` or a `values()` call fed with JSON keys, lets the caller choose aliases and lookups. Two severe Django CVEs have that shape: CVE-2024-42005 (CVSS 7.3 High per NVD; CISA rates it 9.8 Critical) through JSON keys in `values()` and `values_list()` on a `JSONField`, and CVE-2025-57833 (CVSS 8.1 High per NVD) through `FilteredRelation` aliases in `annotate()` and `alias()`. Django's security team fixed both, and current releases reject those aliases. #### Vulnerable and fixed {#code} A report view that searches by surname and sorts by a column the client picks: ```python from django.db import connection from myapp.models import Person def people(request): last = request.GET["last_name"] sort = request.GET.get("sort", "last_name") rows = Person.objects.raw( "SELECT * FROM myapp_person WHERE last_name = '%s' ORDER BY %s" % (last, sort) ) with connection.cursor() as cursor: cursor.execute(f"SELECT count(*) FROM myapp_person WHERE last_name = '{last}'") total = cursor.fetchone()[0] return rows, total ``` The value goes into `params`, unquoted. The column cannot be a parameter, so it comes from a mapping the code owns: ```python from django.db import connection from myapp.models import Person SORTABLE = {"name": "last_name", "born": "birth_date"} def people(request): last = request.GET["last_name"] column = SORTABLE.get(request.GET.get("sort"), "last_name") rows = Person.objects.raw( "SELECT * FROM myapp_person WHERE last_name = %s ORDER BY " + column, [last] ) with connection.cursor() as cursor: cursor.execute("SELECT count(*) FROM myapp_person WHERE last_name = %s", [last]) total = cursor.fetchone()[0] return rows, total ``` In most views the better fix is to drop the raw SQL: `Person.objects.filter(last_name=last).order_by(column)` does the same work with no SQL text in your code. #### What Django already does for you {#built-in} Quite a lot. Querysets are built with query parameterization, so user-provided values are escaped by the database driver. The raw APIs all accept `params`. And the project's security team publishes advisories and fixes quickly, which is why keeping Django current is part of the fix for the alias CVEs above. #### What es-python adds {#es-python} es-python follows the request's bytes through your view and reports when they end up inside the text of a statement. For `raw()` and `extra()` the event is labelled `sql raw`. A string passed to `cursor.execute()` is reported at the database driver's own query call, for the [drivers that ship with es-python](https://eyalsec.com/docs/packages#bundled-libraries). Both carry the `sqli` [impact tag](https://eyalsec.com/docs/events#impact-tags). A value passed in `params` never becomes statement text, so the fixed view is silent on the surname. The sort column is different: it is still request data inside SQL text, so the event still appears, and you record the allowlist decision once with a [rule](https://eyalsec.com/docs/rules). The ready-made [Block backend network attacks](https://eyalsec.com/docs/rule-templates#block-backend) template blocks `sql raw`, the sort-column query included, so under it pass the column through a fixed mapping in code or switch that rule off for the view. Because the check is on the statement and not on your source, it also covers SQL text assembled inside Django or a third-party app. Switch on the [socket source](https://eyalsec.com/docs/taint-sources#socket) and run the test suite or a staging server under es-python to see which statements receive request data. #### Review checklist {#review} 1. Search for `.raw(`, `.extra(`, `RawSQL(` and `cursor.execute(`. Each call needs a constant string and a `params` argument. 2. Remove quotes around `%s` in every raw query. 3. Never expand request data with `**` into `annotate()`, `alias()`, `values()` or `filter()`. 4. Map sort and group-by choices through a dictionary of column names. 5. Keep Django on a supported release. #### References {#references} - Reference: [Django documentation: Performing raw SQL queries](https://docs.djangoproject.com/en/5.2/topics/db/sql/) (checked 2026-10-12) - Reference: [Django documentation: Security in Django, SQL injection protection](https://docs.djangoproject.com/en/5.2/topics/security/) (checked 2026-10-12) - Reference: [Django documentation: QuerySet API, extra()](https://docs.djangoproject.com/en/5.2/ref/models/querysets/) (checked 2026-10-12) - Reference: [Django documentation: Query expressions, RawSQL](https://docs.djangoproject.com/en/5.2/ref/models/expressions/) (checked 2026-10-12) ### SQL injection in Flask URL: https://eyalsec.com/vulnerabilities/sql-injection/flask | Class: https://eyalsec.com/vulnerabilities/sql-injection | Framework: Flask Flask leaves the database to you, so a Flask view is exactly as safe as the query code you write under `request.args`, `request.form` and `request.get_json()`. #### How it arises in Flask {#how} A Flask view receives strings. `request.args.get("q")` returns whatever the client sent, and nothing between the route decorator and your query looks at it. Three shapes account for most findings. **A driver used directly.** Small services open `sqlite3` or a PostgreSQL driver and call `execute()` with an f-string. The driver would have bound the value if it had been passed as the second argument. **Flask-SQLAlchemy with a textual query.** The extension's documented path is `db.session.execute(db.select(User).filter_by(username=username))`, which binds values. The same `db.session.execute()` also accepts `db.text(...)`, and a `text()` built with an f-string is plain SQL concatenation with an ORM import above it. **Typed route converters that are not used.** `@app.route("/orders/")` only matches digits, so `order_id` cannot carry SQL. `@app.route("/orders/")` matches any string. Views that started with the typed form and later moved the value to the query string lose that check without anyone noticing. Search boxes and sort links are where it concentrates: a `LIKE` pattern, an `ORDER BY` column and a page size, all from `request.args`, all tempting to format into one string. #### Vulnerable and fixed {#code} ```python from flask import Flask, request, jsonify from flask_sqlalchemy import SQLAlchemy app = Flask(__name__) app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///shop.db" db = SQLAlchemy(app) @app.get("/orders") def orders(): customer = request.args.get("customer", "") sort = request.args.get("sort", "created_at") sql = db.text( f"SELECT id, total FROM orders WHERE customer = '{customer}' ORDER BY {sort}" ) rows = db.session.execute(sql).all() return jsonify([tuple(r) for r in rows]) ``` The value becomes a named bind parameter. The sort key is looked up in a dictionary, so the only strings that can reach the statement are ones written in the source: ```python from flask import Flask, request, jsonify from flask_sqlalchemy import SQLAlchemy app = Flask(__name__) app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///shop.db" db = SQLAlchemy(app) SORTABLE = {"created": "created_at", "total": "total"} @app.get("/orders") def orders(): customer = request.args.get("customer", "") column = SORTABLE.get(request.args.get("sort"), "created_at") sql = db.text( "SELECT id, total FROM orders WHERE customer = :customer ORDER BY " + column ) rows = db.session.execute(sql, {"customer": customer}).all() return jsonify([tuple(r) for r in rows]) ``` #### What Flask and its extensions already do {#built-in} Flask's converters (`int`, `float`, `uuid`) reject a request before the view runs when the path segment has the wrong shape. Flask-SQLAlchemy's `db.select()` constructs bind every value. SQLAlchemy's `text()` supports `:name` parameters. Used as documented, each of them keeps data out of the statement. None of them can help with a string that was already formatted before it reached them. #### What es-python adds {#es-python} Run the same Flask app with es-python and the [socket source](https://eyalsec.com/docs/taint-sources#socket) on. When characters from the request are present in the statement text at the moment it is executed, an event is recorded: `sql injection` for SQLAlchemy's `text()`, and `sqlite execute` when the code calls the standard `sqlite3` module directly. Each carries the `sqli` [impact tag](https://eyalsec.com/docs/events#impact-tags), the value, and the stack trace down to your view function. Two things make this useful next to code review. It does not depend on the shape of the call, so a query assembled in a helper module, or in a Flask extension you did not write, is reported the same way. And a bound parameter never appears in the statement text, so the fixed view above reports nothing for `customer`. The sort column is still a request-chosen string inside SQL; when EyalSec cannot prove it harmless the event stays, and a [rule](https://eyalsec.com/docs/rules) records that you checked the allowlist. To stop the statement instead of logging it, the ready-made [Block backend network attacks](https://eyalsec.com/docs/rule-templates#block-backend) template raises on `sql injection` and `sqlite execute`; under it the view gets a `RuntimeError` before the database sees the query. #### Review checklist {#review} 1. Search for `text(f"`, `text("...".format`, and `execute(f"`. 2. Prefer `db.select()` and `filter_by()` over textual SQL. 3. Use typed route converters for identifiers in the path. 4. Send `LIKE` patterns as parameters; add the `%` in Python, not in the SQL string. 5. Allowlist sort columns and directions. #### References {#references} - Reference: [Flask-SQLAlchemy documentation: Modifying and Querying Data](https://flask-sqlalchemy.readthedocs.io/en/stable/queries/) (checked 2026-10-12) - Reference: [SQLAlchemy documentation: text()](https://docs.sqlalchemy.org/en/20/core/sqlelement.html) (checked 2026-10-12) - Reference: [Flask documentation: API, URL route registrations](https://flask.palletsprojects.com/en/stable/api/) (checked 2026-10-12) ### SQL injection in FastAPI URL: https://eyalsec.com/vulnerabilities/sql-injection/fastapi | Class: https://eyalsec.com/vulnerabilities/sql-injection | Framework: FastAPI FastAPI checks that a parameter has the declared type before your function runs, which removes SQL injection for integers and enums and leaves it fully in place for every parameter declared as `str`. #### How it arises in FastAPI {#how} Type hints do real security work here. A handler that declares `hero_id: int` receives an integer or the client receives a 422 response; no SQL can ride along. That makes it easy to assume the framework validates input in general. It validates shape. `q: str`, a `str` field on a Pydantic body model, and a header value are all free text. The usual routes into the statement: **Async drivers called directly.** asyncpg uses PostgreSQL's native `$1`, `$2` placeholders: `await conn.fetchrow("SELECT * FROM users WHERE name = $1", name)`. Code written by someone used to `%s` often falls back to an f-string because the placeholder syntax is unfamiliar. **A session dependency plus `text()`.** The FastAPI tutorial's database pattern injects a session with `Depends` and runs `session.exec(select(Hero).offset(offset).limit(limit))`, which binds values. Reporting endpoints then reach for `text()` and format into it. **Filter and sort parameters.** List endpoints accept `sort`, `order` and `fields`. These are identifiers, so they cannot be bound, and they are declared as `str`. **Background work.** A value validated only as a string is handed to `BackgroundTasks.add_task()` and used in a query after the response has been sent, away from the handler where a reviewer would look. #### Vulnerable and fixed {#code} ```python from fastapi import Depends, FastAPI from sqlalchemy import text from sqlalchemy.orm import Session app = FastAPI() def get_session(): raise NotImplementedError @app.get("/invoices") def invoices(customer: str, sort: str = "issued_at", session: Session = Depends(get_session)): stmt = text(f"SELECT id, amount FROM invoices WHERE customer = '{customer}' ORDER BY {sort}") return [dict(r._mapping) for r in session.execute(stmt)] ``` FastAPI's own idiom fixes the identifier: declare the parameter as an `Enum`, and a value outside the list is rejected before the function body runs. The value becomes a bind parameter: ```python from enum import Enum from fastapi import Depends, FastAPI from sqlalchemy import text from sqlalchemy.orm import Session app = FastAPI() class InvoiceSort(str, Enum): issued_at = "issued_at" amount = "amount" def get_session(): raise NotImplementedError @app.get("/invoices") def invoices( customer: str, sort: InvoiceSort = InvoiceSort.issued_at, session: Session = Depends(get_session), ): stmt = text("SELECT id, amount FROM invoices WHERE customer = :customer ORDER BY " + sort.value) return [dict(r._mapping) for r in session.execute(stmt, {"customer": customer})] ``` #### What FastAPI already does for you {#built-in} Validation by type hint is the framework's strongest protection and it is on by default: integers, UUIDs, dates and enums arrive parsed or not at all. The documented SQL path through SQLModel and `select()` binds values. Pydantic models give you one place to tighten a string field with a length limit or a pattern. What the framework cannot know is which of your `str` parameters will later be placed inside a statement. #### What es-python adds {#es-python} es-python marks what the server reads from the connection and keeps following it after FastAPI and Pydantic have parsed it, through the awaits, into the session dependency or the background task. If request characters are in the statement text when the driver sends it, the `sql injection` event fires: that label covers asyncpg and SQLAlchemy's `text()`, which are the two most common choices in FastAPI services. The event carries the `sqli` [impact tag](https://eyalsec.com/docs/events#impact-tags) and a trace that ends in your path operation function. The fixed endpoint behaves differently for its two inputs. `customer` is bound, so it is never part of the SQL and produces no event. `sort` is an enum member, yet its characters did arrive in the request; if EyalSec cannot prove them harmless the event remains, graded from its [source](https://eyalsec.com/docs/taint-sources#socket), and you settle it once with a [rule](https://eyalsec.com/docs/rules) for that call site. The [Block backend network attacks](https://eyalsec.com/docs/rule-templates#block-backend) template turns `sql injection` from async drivers into a `RuntimeError` raised before the query runs. #### Review checklist {#review} 1. List every `str` parameter that reaches a query, including body model fields. 2. Declare sort, order and field selectors as `Enum` or `Literal` types. 3. Use `$1` placeholders with asyncpg and `:name` with `text()`. 4. Review functions passed to `add_task()` as carefully as handlers. 5. Search for `text(f"` and `fetch(f"`. #### References {#references} - Reference: [FastAPI documentation: SQL (Relational) Databases](https://fastapi.tiangolo.com/tutorial/sql-databases/) (checked 2026-10-12) - Reference: [FastAPI documentation: Path Parameters, predefined values](https://fastapi.tiangolo.com/tutorial/path-params/) (checked 2026-10-12) - Reference: [FastAPI documentation: Background Tasks](https://fastapi.tiangolo.com/tutorial/background-tasks/) (checked 2026-10-12) - Reference: [asyncpg documentation: Usage](https://magicstack.github.io/asyncpg/current/usage.html) (checked 2026-10-12) ### SQL injection in SQLAlchemy URL: https://eyalsec.com/vulnerabilities/sql-injection/sqlalchemy | Class: https://eyalsec.com/vulnerabilities/sql-injection | Framework: SQLAlchemy SQLAlchemy's expression language turns Python values into bound parameters automatically, so injection in SQLAlchemy code lives in the constructs whose job is to accept SQL text exactly as written. #### How it arises in SQLAlchemy {#how} `select(User).where(User.name == name)` never puts `name` in the statement. The compiler emits a placeholder and sends the value separately. The constructs below are the exceptions, and each exists for a good reason. - **`text()`** builds a `TextClause` "representing a textual SQL string directly". It supports named parameters written as `:name`, supplied at execution or with `TextClause.bindparams()`. A `text()` that receives an f-string has no parameters left to bind. - **`literal_column()`** produces a column expression that, in the documentation's words, renders exactly as stated. It is meant for SQL expressions the library has no construct for. Fed with a request value, it is a concatenation point. - **`column()`** stores a name that is assumed to be part of a table and may be quoted as such. Quoting is not an allowlist: the caller still chooses which column. - **Textual fragments inside ORM queries**, for example `.where(text(...))`, `.order_by(text(...))` and `.group_by(text(...))`. These mix freely with safe constructs, so a query can be 95% expression language and still injectable in its last clause. - **`Connection.exec_driver_sql()`**, which hands a string straight to the DBAPI driver. Dynamic filtering is the usual trigger. An endpoint accepts a column name and an operator, and the shortest implementation is a formatted string inside `text()`. #### Vulnerable and fixed {#code} ```python from sqlalchemy import select, text from sqlalchemy.orm import Session from models import Order def search_orders(session: Session, status: str, sort: str, direction: str): stmt = ( select(Order) .where(text(f"status = '{status}'")) .order_by(text(f"{sort} {direction}")) ) return session.scalars(stmt).all() ``` Stay in the expression language. The column is fetched from a dictionary of mapped attributes, and the direction is a method call chosen in Python, so no request string is rendered into SQL: ```python from sqlalchemy import select from sqlalchemy.orm import Session from models import Order SORTABLE = {"created": Order.created_at, "total": Order.total} def search_orders(session: Session, status: str, sort: str, direction: str): column = SORTABLE.get(sort, Order.created_at) ordering = column.desc() if direction == "desc" else column.asc() stmt = select(Order).where(Order.status == status).order_by(ordering) return session.scalars(stmt).all() ``` When textual SQL is the right tool, for example a hand-tuned report, keep the string constant and pass the values: `session.execute(text("SELECT ... WHERE status = :status"), {"status": status})`. #### What SQLAlchemy already does for you {#built-in} Nearly everything. Comparison operators, `in_()`, `like()`, `insert().values()` and ORM attribute assignment all produce bound parameters without the developer asking. Identifiers that come from mapped classes are quoted by the dialect. `text()` has first-class parameter support. The textual constructs are clearly named, which makes them easy to search for in review. #### What es-python adds {#es-python} Review finds the `text(f"...")` you can see. es-python reports the ones you cannot: a fragment built three calls away, a helper that wraps `literal_column()`, a query builder in a dependency. It follows data read from the network, a file or another [source you switch on](https://eyalsec.com/docs/taint-sources), and when that data is part of a textual construct at execution it records a `sql injection` event with the `sqli` [impact tag](https://eyalsec.com/docs/events#impact-tags), the value and the Python stack. Statements built entirely from the expression language carry their values as parameters, so they are silent. That makes the event list a short, accurate inventory: every row is a place where outside data became SQL text. Some of those rows will be deliberate and guarded by an allowlist. When EyalSec can prove the outside characters could not change the statement, the event is [suppressed](https://eyalsec.com/docs/suppressed-events); otherwise you mark the call site with a [rule](https://eyalsec.com/docs/rules). Second-order flows are covered too. With the [db_rows source](https://eyalsec.com/docs/taint-sources#db-rows) on, a value read from one table and later formatted into a `text()` for another is reported, although no request carried it that day. `sql injection` is one of the labels the [Block backend network attacks](https://eyalsec.com/docs/rule-templates#block-backend) template can block. #### Review checklist {#review} 1. Search for `text(`, `literal_column(`, `exec_driver_sql(` and string arguments to `order_by()`. 2. Every `text()` should be a constant with `:name` parameters. 3. Resolve column names through a dictionary of mapped attributes. 4. Choose `asc()` or `desc()` in Python; do not render a direction string. 5. Keep report SQL in constants, not in builders that accept fragments. #### References {#references} - Reference: [SQLAlchemy documentation: Column Elements and Expressions (text, literal_column, column)](https://docs.sqlalchemy.org/en/20/core/sqlelement.html) (checked 2026-10-12) - Reference: [SQLAlchemy Unified Tutorial: Working with Transactions and the DBAPI, sending parameters](https://docs.sqlalchemy.org/en/20/tutorial/dbapi_transactions.html) (checked 2026-10-12) - Reference: [SQLAlchemy documentation: Working with Engines and Connections](https://docs.sqlalchemy.org/en/20/core/connections.html) (checked 2026-10-12) ### SQL injection in psycopg URL: https://eyalsec.com/vulnerabilities/sql-injection/psycopg | Class: https://eyalsec.com/vulnerabilities/sql-injection | Framework: psycopg psycopg keeps values out of the statement whenever they are passed as the second argument to `execute()`, so every SQL injection through it comes from merging values into the query string in Python first. #### How it arises in psycopg {#how} The placeholder is `%s`, which is also Python's string formatting marker. That overlap is the root of most mistakes. These two lines differ by one character and behave completely differently: ```python def lookup(cur, email): cur.execute("SELECT id FROM accounts WHERE email = %s" % email) cur.execute("SELECT id FROM accounts WHERE email = %s", (email,)) ``` The first formats in Python and sends finished SQL. The second sends the statement and the value to the driver, which is the form the psycopg documentation calls correct. The documentation is unusually direct about the first: never use `%` or `+` to merge values into queries. Four details from the same documentation matter in practice: - **The placeholder must not be quoted.** `'%s'` is wrong; the driver adds quoting itself. - **It is always `%s`**, even for numbers. `%d` and `%f` are not accepted. - **Named placeholders** are written `%(name)s` and take a mapping. - **Table and column names cannot be parameters.** `cur.execute("INSERT INTO %s VALUES (%s)", ("numbers", 10))` fails, because the table name would be sent as a string literal. The last point pushes developers back to string formatting for dynamic identifiers, which is why psycopg provides the `psycopg.sql` module. #### Vulnerable and fixed {#code} An export job that takes a table name and a tenant from a message: ```python import psycopg def export_rows(conn: psycopg.Connection, table: str, tenant: str): with conn.cursor() as cur: cur.execute("SELECT * FROM " + table + " WHERE tenant_id = '%s'" % tenant) return cur.fetchall() ``` `sql.SQL` is a template for the statement's fixed text, `sql.Identifier` quotes a name by PostgreSQL's identifier rules, and the tenant stays a bound value. The allowlist still decides which tables may be exported at all: ```python import psycopg from psycopg import sql EXPORTABLE = {"invoices", "payments"} def export_rows(conn: psycopg.Connection, table: str, tenant: str): if table not in EXPORTABLE: raise ValueError("table is not exportable") query = sql.SQL("SELECT * FROM {} WHERE tenant_id = %s").format(sql.Identifier(table)) with conn.cursor() as cur: cur.execute(query, (tenant,)) return cur.fetchall() ``` #### What psycopg already does for you {#built-in} The driver converts Python values to PostgreSQL's representation, so quoting is never the caller's job. `psycopg.sql` composes identifiers and fixed SQL without string operators. In psycopg 3, `sql.SQL` is annotated to take a `LiteralString`, so static type analysis can flag a call that builds its template from a variable. `sql.Literal` exists for the rare value that must be written into the statement, and the documentation steers you to placeholders first. #### What es-python adds {#es-python} psycopg2 and psycopg 3 are among the [libraries that ship with es-python](https://eyalsec.com/docs/packages#bundled-libraries), and es-python's copy is the one your program imports by default. At the driver's query call it looks at the statement text. If part of that text came from a source you switched on, such as the [network](https://eyalsec.com/docs/taint-sources#socket) or a queue message read from it, an event is recorded with the `sqli` [impact tag](https://eyalsec.com/docs/events#impact-tags). For psycopg2 the events list names the operation `psycopg2 cursor.execute()`. Because the check sits at the driver, it does not matter what is above it. Django's `cursor.execute()`, a hand-written repository class and a migration script all end in the same call. Values sent as parameters are not statement text and produce nothing. Three practical notes. An identifier composed with `sql.Identifier` is still outside data inside the statement, so the event can still appear; open it, confirm the allowlist, and record the decision with a [rule](https://eyalsec.com/docs/rules). If you set `ES2_PREFER_APP_LIBS=psycopg2` to use your own build of the driver, that driver is not watched, and the machine reports that as a [coverage gap](https://eyalsec.com/docs/packages#coverage-gaps), so silence is not mistaken for safety. And the ready-made [Block backend network attacks](https://eyalsec.com/docs/rule-templates#block-backend) template blocks psycopg2 statements built from a request, the `sql.Identifier` case included, while parameterized queries run as usual. #### Review checklist {#review} 1. Search for `execute(` followed by `%`, `+`, `.format(` or an f-string. 2. Remove quotes around placeholders and replace `%d` with `%s`. 3. Compose identifiers with `sql.Identifier`, after an allowlist check. 4. Give the application role only the privileges it needs. 5. Run static type analysis (mypy or pyright) so `LiteralString` annotations are enforced. #### References {#references} - Reference: [psycopg 3 documentation: Passing parameters to SQL queries](https://www.psycopg.org/psycopg3/docs/basic/params.html) (checked 2026-10-12) - Reference: [psycopg 3 documentation: SQL string composition](https://www.psycopg.org/psycopg3/docs/api/sql.html) (checked 2026-10-12) - Reference: [psycopg2 documentation: Basic module usage](https://www.psycopg.org/docs/usage.html) (checked 2026-10-12) ### Command injection in Django URL: https://eyalsec.com/vulnerabilities/command-injection/django | Class: https://eyalsec.com/vulnerabilities/command-injection | Framework: Django Django never runs a shell for you, so command injection in a Django project is always application code calling `subprocess` or `os.system()`, usually in a place that was written for an operator and later became reachable from a request. #### How it arises in Django {#how} **Management commands that shell out.** A command under `management/commands/` wraps `pg_dump`, `rsync` or an image tool. Its arguments come from `add_arguments()` and arrive in `handle()` as `options[...]`. Run from a terminal, the person typing already has a shell, so building the command line with an f-string and `shell=True` does no harm. The same command becomes a remote entry point the day a view or an admin action starts it. **`call_command()` with request data.** `django.core.management.call_command(name, *args, **options)` passes `*args` to the command's argument parser, in the same style as the command line; the documentation's example is `call_command('flush', '--verbosity=0')`. So a positional value taken from a request that starts with `--` is parsed as an option. Even with no shell anywhere, the caller can switch on flags the view never meant to expose. **Uploaded file names.** `UploadedFile.name` is the name the browser sent. A view that hands it to a converter as an argument has given the client a way to pass an option to that program, and with `shell=True`, a way to run another one. **Celery tasks.** The view validates nothing because "the task does the work". The task trusts its arguments because "the view checked them". The command is built in the worker, far from the request. #### Vulnerable and fixed {#code} An admin action that exports one app's data for a support ticket: ```python import subprocess from django.http import HttpResponse def export_app(request): app_label = request.POST["app"] out = "/srv/exports/" + request.POST["ticket"] + ".json" subprocess.run(f"python manage.py dumpdata {app_label} > {out}", shell=True, check=True) return HttpResponse("ok") ``` The fixed view has no shell and no argument parsing of request data. The app label is checked against the installed apps, the ticket number is forced to digits, and the options are passed to `call_command()` as keywords, which are not parsed as command-line text: ```python from django.apps import apps from django.core.management import call_command from django.http import HttpResponse, HttpResponseBadRequest def export_app(request): app_label = request.POST["app"] ticket = request.POST["ticket"] if app_label not in {a.label for a in apps.get_app_configs()} or not ticket.isdigit(): return HttpResponseBadRequest("unknown app or ticket") call_command("dumpdata", app_label, output=f"/srv/exports/{ticket}.json") return HttpResponse("ok") ``` #### What Django and Python already do for you {#built-in} `subprocess` does not choose a shell on its own. The Python documentation states that, because of this, all characters, including shell metacharacters, can safely be passed to child processes when you give it a list. Django adds forms and validators that make the allowlist a one-liner, and `call_command()` accepts options as keyword arguments so you do not have to build option strings at all. #### What es-python adds {#es-python} The interesting property in a Django project is that es-python grades the same line of code differently depending on who supplied the data. A management command run by an operator takes its values from the process's own command line; an event there is graded Info, because whoever typed the line could run the command anyway. Reach the same `subprocess.run()` from a view or a Celery message and the data came from the [network](https://eyalsec.com/docs/taint-sources#socket), so the event is Critical. You see which of your operator tools have become request-reachable without auditing every caller. The labels describe the shape. `subprocess shell` means the program ran `sh -c` on a string containing request data, as in the vulnerable view. `shell injection` marks an untrusted argument that contains a shell metacharacter, and `argument injection` one that starts with `-`. A base `subprocess exec` event records every program started with request data in its arguments, so the fixed code still shows up if it runs a program, without the sharper labels. All of them carry the `command injection` or `argument injection` [impact tag](https://eyalsec.com/docs/events#impact-tags). Under the [Block network code execution](https://eyalsec.com/docs/rule-templates#block-network-rce) template the shell forms raise a `RuntimeError` in the view before the command starts. #### Review checklist {#review} 1. Search for `shell=True`, `os.system(` and `os.popen(` across apps, commands and tasks. 2. List which management commands are started from views, signals or tasks. 3. Pass request values to `call_command()` as keyword options, never as positional strings. 4. Put `--` before file name arguments for programs that support it. 5. Validate in the task as well as in the view. #### References {#references} - Reference: [Django documentation: django-admin and manage.py, call_command()](https://docs.djangoproject.com/en/5.2/ref/django-admin/) (checked 2026-10-12) - Reference: [Django documentation: How to create custom django-admin commands](https://docs.djangoproject.com/en/5.2/howto/custom-management-commands/) (checked 2026-10-12) - Reference: [Django documentation: Uploaded Files and Upload Handlers](https://docs.djangoproject.com/en/5.2/ref/files/uploads/) (checked 2026-10-12) - Reference: [Python documentation: subprocess, Security Considerations](https://docs.python.org/3/library/subprocess.html) (checked 2026-10-12) ### Command injection in Flask URL: https://eyalsec.com/vulnerabilities/command-injection/flask | Class: https://eyalsec.com/vulnerabilities/command-injection | Framework: Flask Flask is the framework people reach for when they need a small HTTP front for a command-line tool, which makes "take a request, run a program" the most common Flask view that goes wrong. #### How it arises in Flask {#how} **Webhooks and deploy hooks.** A route receives JSON from a code host or a CI system and runs `git`, `docker` or a deploy script with fields from the payload. The author thinks of the sender as trusted. The route is a public URL, and unless the signature is verified first, anyone can post to it. **Wrappers around tools.** Internal services expose `ping`, `nslookup`, `ffmpeg`, `wkhtmltopdf` or ImageMagick over HTTP. The value from `request.args` or `request.form` goes into the command line. **Upload handlers.** `request.files["file"].filename` is client-chosen. Flask's upload pattern runs it through `werkzeug.utils.secure_filename()`, which keeps only ASCII letters, digits and `._-` so the name is safe to join to a directory. That function is about paths. Its result can still be read as an option by the program that receives it, and it may be an empty string. **CLI commands reused in views.** `@app.cli.command()` registers a Click command for operators. Calling the same function from a route moves operator-only string building onto the network. Two details make Flask code specifically prone to the shell form. Examples are short, so `shell=True` with an f-string is the first thing that works. And quoting with `shlex.quote()` feels like a fix; it stops shell metacharacters, but a value such as `--upload-pack=...` is still a valid single token that `git` will act on. #### Vulnerable and fixed {#code} ```python import subprocess from flask import Flask, request app = Flask(__name__) @app.post("/hooks/deploy") def deploy(): payload = request.get_json() repo = payload["repository"]["clone_url"] branch = payload["ref"] subprocess.run(f"git clone --branch {branch} {repo} /srv/build", shell=True, check=True) return {"status": "started"} ``` The fix removes the shell, allowlists the repository, restricts the branch to the characters a branch needs, and uses `--` so nothing after it can be parsed as an option: ```python import re import subprocess from flask import Flask, abort, request app = Flask(__name__) REPOS = {"https://git.example.com/acme/site.git"} BRANCH = re.compile(r"[A-Za-z0-9._/]{1,80}") @app.post("/hooks/deploy") def deploy(): payload = request.get_json() repo = payload["repository"]["clone_url"] branch = payload["ref"] if repo not in REPOS or not BRANCH.fullmatch(branch) or branch.startswith("-"): abort(400) subprocess.run(["git", "clone", "--branch", branch, "--", repo, "/srv/build"], check=True) return {"status": "started"} ``` #### What Flask and Python already do for you {#built-in} Flask parses the body and gives you typed JSON, and its documentation teaches `secure_filename()` for every upload. Python's `subprocess` never invokes a shell unless asked, and its documentation says that with a list, shell metacharacters can safely be passed to child processes. `shlex.quote()` is there for the cases where a shell string is unavoidable. Verifying the webhook signature before doing anything else is the control that removes anonymous callers. #### What es-python adds {#es-python} For a webhook service the first question is simply "which programs does a request make us run, and with what". Run the app under es-python with the [socket source](https://eyalsec.com/docs/taint-sources#socket) on and replay a few real deliveries. Every program started with request bytes in its command line appears as a `subprocess exec` event, with the argument, the remote origin and the view that started it. Sharper labels are added from the command as it is about to run, not from how the source reads. `subprocess shell` is the `shell=True` case: a shell is about to interpret request data. `shell injection` means an untrusted argument still contains a metacharacter. `argument injection` means one starts with `-`, which is the `git` case that `shlex.quote()` leaves open. These carry the `command injection` and `argument injection` [impact tags](https://eyalsec.com/docs/events#impact-tags), and data from the network is graded Critical. The fixed view still starts `git` with request data, so its base event remains. That is deliberate: when EyalSec can prove the value could not leave its argument, the event is [suppressed](https://eyalsec.com/docs/suppressed-events) and kept out of the default view. To block, a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) on the shell labels gives the view a `RuntimeError` before the process is created. #### Review checklist {#review} 1. Verify webhook signatures before reading the payload. 2. Replace every `shell=True` with a list. 3. Add `--` before positional arguments that come from a request. 4. Treat `secure_filename()` output as a path component, not as a safe argument. 5. Keep Click commands and view functions separate. #### References {#references} - Reference: [Flask documentation: Uploading Files](https://flask.palletsprojects.com/en/stable/patterns/fileuploads/) (checked 2026-10-12) - Reference: [Werkzeug documentation: Utilities, secure_filename()](https://werkzeug.palletsprojects.com/en/stable/utils/) (checked 2026-10-12) - Reference: [Flask documentation: Command Line Interface](https://flask.palletsprojects.com/en/stable/cli/) (checked 2026-10-12) - Reference: [Python documentation: shlex.quote()](https://docs.python.org/3/library/shlex.html) (checked 2026-10-12) ### Command injection in FastAPI URL: https://eyalsec.com/vulnerabilities/command-injection/fastapi | Class: https://eyalsec.com/vulnerabilities/command-injection | Framework: FastAPI In an async FastAPI service the shell usually enters through `asyncio.create_subprocess_shell()`, chosen because a blocking `subprocess.run()` would stall the event loop. #### How it arises in FastAPI {#how} **The async subprocess API has two entry points.** `asyncio.create_subprocess_exec(program, *args)` starts a program with separate arguments. `asyncio.create_subprocess_shell(cmd)` runs a shell command string. The second is one call shorter when you want a pipeline or a redirect, and the Python documentation attaches a warning to it: it is the application's responsibility to quote whitespace and special characters to avoid shell injection. **Background tasks.** Media and document services accept an upload, return `202`, and convert the file in `BackgroundTasks.add_task()` or a worker. `UploadFile.filename` is whatever the client sent. The command is built after the response, in a function no request handler test exercises. **Validated is not the same as safe.** A Pydantic model guarantees that `host` is a string and perhaps that it is under 255 characters. It says nothing about `;` or a leading `-`. Because the handler signature looks strict, reviewers relax. **Service-to-service calls.** FastAPI is popular for internal APIs. "Only our other services call this" is true until one of them forwards a user's value unchanged. #### Vulnerable and fixed {#code} A diagnostics endpoint for a network team: ```python import asyncio from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class Probe(BaseModel): host: str count: int = 3 @app.post("/diag/ping") async def ping(probe: Probe): proc = await asyncio.create_subprocess_shell( f"ping -c {probe.count} {probe.host}", stdout=asyncio.subprocess.PIPE, ) out, _ = await proc.communicate() return {"output": out.decode()} ``` `count` is an `int`, so only `host` is dangerous. The fix uses the exec form, constrains the field with a pattern that cannot start with a dash, bounds the count, and separates options from the operand: ```python import asyncio from fastapi import FastAPI from pydantic import BaseModel, Field app = FastAPI() class Probe(BaseModel): host: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$") count: int = Field(default=3, ge=1, le=10) @app.post("/diag/ping") async def ping(probe: Probe): proc = await asyncio.create_subprocess_exec( "ping", "-c", str(probe.count), "--", probe.host, stdout=asyncio.subprocess.PIPE, ) out, _ = await proc.communicate() return {"output": out.decode()} ``` #### What FastAPI and Python already do for you {#built-in} Type validation removes whole parameters from the problem: an `int`, an `Enum` or an `IPvAnyAddress` field cannot carry shell syntax. `Field(pattern=...)` puts the allowlist in the model, where the generated OpenAPI schema documents it for callers. On the Python side, the exec form passes arguments without any shell, and `shlex.quote()` is documented for the cases where a shell string cannot be avoided. #### What es-python adds {#es-python} Async code is where following a value by reading gets hard: the request is parsed in one coroutine, queued, and used in another after several awaits. es-python follows the data itself, so the hop through `add_task()` or an `asyncio.Queue` does not lose it. At the moment a process is created with request data in its command, it records the event with the trace of the coroutine that did it. What you see for the vulnerable endpoint is a `subprocess shell` event: a shell is about to run a string that contains bytes from the [network](https://eyalsec.com/docs/taint-sources#socket). It is graded Critical and tagged `command injection` in the [events list](https://eyalsec.com/docs/events#impact-tags). After the fix, the service still runs `ping` with a request value, so a base `subprocess exec` event remains without the shell label. If the host began with a dash you would also get `argument injection`; the pattern above rules that out, and the absence of that label on real traffic is the confirmation. With the [Block network code execution](https://eyalsec.com/docs/rule-templates#block-network-rce) template, or any [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) on the shell labels, the coroutine gets a `RuntimeError` at the call and the shell never starts; your exception handler turns that into a 500 like any other error. #### Review checklist {#review} 1. Search for `create_subprocess_shell`, `shell=True` and `os.system`. 2. Give every string field that reaches a command a `pattern`. 3. Review background task functions and workers, not only handlers. 4. Add `--` before operands. 5. Do not treat internal callers as a validation layer. #### References {#references} - Reference: [Python documentation: asyncio Subprocesses](https://docs.python.org/3/library/asyncio-subprocess.html) (checked 2026-10-12) - Reference: [FastAPI documentation: Background Tasks](https://fastapi.tiangolo.com/tutorial/background-tasks/) (checked 2026-10-12) - Reference: [FastAPI documentation: Request Files](https://fastapi.tiangolo.com/tutorial/request-files/) (checked 2026-10-12) - Reference: [Pydantic documentation: Fields](https://docs.pydantic.dev/latest/concepts/fields/) (checked 2026-10-12) ### Code injection in Django URL: https://eyalsec.com/vulnerabilities/code-injection/django | Class: https://eyalsec.com/vulnerabilities/code-injection | Framework: Django Django is built on dotted-path strings and attribute names, so code injection in a Django project is less often a literal `eval()` and more often a request value used where the framework expects a name. #### How it arises in Django {#how} **`import_string()` and friends.** `django.utils.module_loading.import_string(dotted_path)` imports a module and returns the attribute named by the last segment. Settings use it everywhere (`MIDDLEWARE`, `AUTHENTICATION_BACKENDS`, storage backends), and that is fine: settings are written by the developer. The same helper called with a value from a request, a database row or an uploaded configuration file lets the sender import any module on the path, and importing a module runs its top-level code. **Dispatch by name.** A view reads `request.POST["action"]` and calls `getattr(self, action)()`. It works for the three intended actions and for every other method on the class and its bases, including ones that delete. **Assignment by name.** The mirror image: `for key, value in request.POST.items(): setattr(obj, key, value)`. The form posts `display_name`; nothing stops a client from adding `is_staff`. This is the unsafe reflection end of the class. It writes attributes instead of running text, and on a model instance followed by `save()` the effect is a privilege change. **`eval()` in back-office tools.** Pricing rules, report formulas and feature-flag conditions stored as Python expressions and evaluated per request. They sit behind the admin, which is treated as trusted until one staff account is phished. #### Vulnerable and fixed {#code} A profile endpoint that updates "whatever fields were sent" and runs a bulk action by name: ```python from django.http import JsonResponse def update_profile(request): profile = request.user.profile for key, value in request.POST.items(): setattr(profile, key, value) profile.save() action = request.POST.get("action") if action: getattr(profile, action)() return JsonResponse({"ok": True}) ``` Django's own answer is a `ModelForm` with an explicit `fields` list: only those attributes can be written, and they are validated on the way in. Actions go through a dictionary of callables, so the request selects among functions the developer listed: ```python from django import forms from django.http import JsonResponse from myapp.models import Profile class ProfileForm(forms.ModelForm): class Meta: model = Profile fields = ["display_name", "timezone"] ACTIONS = {"resend_welcome": lambda p: p.resend_welcome()} def update_profile(request): form = ProfileForm(request.POST, instance=request.user.profile) if not form.is_valid(): return JsonResponse({"errors": form.errors}, status=400) profile = form.save() action = ACTIONS.get(request.POST.get("action")) if action: action(profile) return JsonResponse({"ok": True}) ``` #### What Django already does for you {#built-in} `ModelForm` has a `fields` attribute, and the documentation strongly recommends setting it explicitly, because a form that picks up new model fields automatically can let a user set them without anyone noticing. The admin registers actions explicitly. `apps.get_model()` resolves only installed models, which is a far narrower lookup than a general import. Django's template language cannot call arbitrary Python. Used as intended, the framework keeps names chosen by the developer and values chosen by the user apart. #### What es-python adds {#es-python} Name-based flows are hard to review because the dangerous line contains no dangerous-looking function. es-python reports them by what happens: a name that came from the request was used to write an attribute, look one up, import a module or compile code. - A request-supplied name passed to `setattr()` is reported as `setattr`, tagged `unsafe reflection`. A write onto a module or a class, which replaces code, is reported as `monkey patch`. - `getattr()` with a request-supplied name is reported as `getattr`. Most such calls are serializers reading a field, so EyalSec keeps the event only when the name reaches an introspection attribute such as `__class__` or `__globals__`, and [suppresses](https://eyalsec.com/docs/suppressed-events) the rest. - `eval`, `exec` and `compile` on request data carry the `rce` [impact tag](https://eyalsec.com/docs/events#impact-tags). An import by computed name is reported as `__import__`; a name passed to `importlib.import_module()` is reported where the import system looks that name up on disk. The fixed view produces none of these: the form writes fields named in source, and the dictionary lookup involves no reflection call. With data from the [network](https://eyalsec.com/docs/taint-sources#socket) the code-running labels are graded Critical, and under the [Block network code execution](https://eyalsec.com/docs/rule-templates#block-network-rce) template `eval` and `exec` raise a `RuntimeError` before anything is compiled. #### Review checklist {#review} 1. Search for `import_string(`, `import_module(`, `getattr(` and `setattr(` whose name argument is not a literal. 2. Replace attribute loops with a `ModelForm` or a serializer that lists its fields. 3. Dispatch through dictionaries of callables. 4. Move stored formulas to a small expression parser, not `eval()`. 5. Treat admin-entered code as untrusted input. #### References {#references} - Reference: [Django documentation: Django Utils, import_string()](https://docs.djangoproject.com/en/5.2/ref/utils/) (checked 2026-10-12) - Reference: [Django documentation: Applications, apps.get_model()](https://docs.djangoproject.com/en/5.2/ref/applications/) (checked 2026-10-12) - Reference: [Django documentation: Creating forms from models, selecting the fields to use](https://docs.djangoproject.com/en/5.2/topics/forms/modelforms/) (checked 2026-10-12) ### Code injection in Flask URL: https://eyalsec.com/vulnerabilities/code-injection/flask | Class: https://eyalsec.com/vulnerabilities/code-injection | Framework: Flask Flask stays out of your way, which means nothing stands between a query string and `eval()` except the view you wrote, and one Flask development feature runs Python from the browser by design. #### How it arises in Flask {#how} **The debugger.** Running with `--debug` enables Werkzeug's interactive debugger. Flask's documentation is plain about it: the debugger allows executing arbitrary Python code from the browser, it is protected by a PIN that should not be relied on for security, and it must not be enabled in a production environment. It still reaches production through a copied `app.run(debug=True)` or an environment variable left in a container image. **`eval()` behind small features.** A unit converter, a spreadsheet-style formula field, a "filter" parameter such as `?where=price<10`. Python's own evaluator is the shortest implementation. `request.args["expr"]` goes straight in. **Import by name.** `werkzeug.utils.import_string(import_name)` imports an object from a dotted path or a `module:object` string; Werkzeug describes it as useful for import paths used as endpoints. Flask's `app.config.from_object()` accepts the same kind of string. Both are meant for names the developer writes. A plugin system that loads `import_string(request.json["handler"])` imports whatever module the caller names, and importing runs that module's top level. **Template context helpers.** A helper registered with `app.jinja_env.globals` that evaluates a string turns any template variable into code. #### Vulnerable and fixed {#code} ```python from flask import Flask, jsonify, request from werkzeug.utils import import_string app = Flask(__name__) @app.get("/calc") def calc(): return jsonify(result=eval(request.args["expr"])) @app.post("/export") def export(): exporter = import_string("exporters." + request.json["format"]) return exporter(request.json["rows"]) ``` Arithmetic does not need Python's evaluator. A walk over the parsed syntax tree accepts numbers and four operators and rejects everything else. The exporter is chosen from a dictionary: ```python import ast import operator from flask import Flask, abort, jsonify, request import exporters app = Flask(__name__) OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv} EXPORTERS = {"csv": exporters.to_csv, "json": exporters.to_json} def arithmetic(node): if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): return node.value if isinstance(node, ast.BinOp) and type(node.op) in OPS: return OPS[type(node.op)](arithmetic(node.left), arithmetic(node.right)) raise ValueError("unsupported expression") @app.get("/calc") def calc(): try: tree = ast.parse(request.args["expr"][:200], mode="eval") return jsonify(result=arithmetic(tree.body)) except (ValueError, SyntaxError, ZeroDivisionError): abort(400) @app.post("/export") def export(): exporter = EXPORTERS.get(request.json.get("format")) if exporter is None: abort(400) return exporter(request.json["rows"]) ``` #### What Flask already does for you {#built-in} Debug mode is off unless you ask for it, and the documentation warns against it in production in two separate places. The debugger is PIN-protected as a second line. Jinja templates rendered by `render_template()` cannot run arbitrary Python statements. Route converters keep path segments typed. Flask does not offer an "evaluate this" helper; every `eval()` in a Flask app was added by hand, so a search finds them. #### What es-python adds {#es-python} The Flask-specific event here is `interactive console`. es-python reports it when a console runs input that came from the network, which is what a reachable debugger looks like at runtime. It carries the `rce` [impact tag](https://eyalsec.com/docs/events#impact-tags), and the [Block network code execution](https://eyalsec.com/docs/rule-templates#block-network-rce) template blocks it. If that event ever appears on a production machine, the debugger is on and someone used it. For the view code, `eval`, `exec` and `compile` are reported when their argument contains request data, graded Critical for data from the [network](https://eyalsec.com/docs/taint-sources#socket). An import by computed name is reported as `__import__`. One thing to expect from the fixed version: `ast.parse()` cannot run code, but it does compile its input to a syntax tree, so es-python reports it as `compile` when the input is untrusted. That is accurate and, here, intended. Record the decision with a [rule](https://eyalsec.com/docs/rules) for that call site so it stays out of your default view. This is also why the [Block network code execution](https://eyalsec.com/docs/rule-templates#block-network-rce) template raises on `eval` and `exec` but leaves `compile` alone: under it, the vulnerable `/calc` gets a `RuntimeError` before the expression runs, and the fixed one keeps working. #### Review checklist {#review} 1. Confirm debug mode is off in every deployed configuration and image. 2. Search for `eval(`, `exec(`, `import_string(` and `__import__(`. 3. Replace expression evaluation with a syntax-tree walk or a small parser. 4. Load plugins from a dictionary or from entry points you control. 5. Keep evaluators out of Jinja globals and filters. #### References {#references} - Reference: [Flask documentation: Debugging Application Errors](https://flask.palletsprojects.com/en/stable/debugging/) (checked 2026-10-12) - Reference: [Werkzeug documentation: Utilities, import_string()](https://werkzeug.palletsprojects.com/en/stable/utils/) (checked 2026-10-12) - Reference: [Flask documentation: Configuration Handling](https://flask.palletsprojects.com/en/stable/config/) (checked 2026-10-12) ### Code injection in FastAPI URL: https://eyalsec.com/vulnerabilities/code-injection/fastapi | Class: https://eyalsec.com/vulnerabilities/code-injection | Framework: FastAPI FastAPI endpoints look strict because every parameter has a type, and that appearance hides two ways a request can still choose code: a Pydantic type that imports by name, and handlers that load a module from a path or body value. #### How it arises in FastAPI {#how} **`ImportString` on a request model.** Pydantic provides `ImportString`, a type that expects a string and loads the Python object importable at that dotted path; `math:cos` becomes the function `cos`. Pydantic's documentation notes that the pointer is evaluated and imported on model instantiation. It is designed for settings. Put on a model that FastAPI builds from a request body, validation itself performs an import the client chose, before the first line of your handler. **Plugin loading by name.** `/run/{task}` followed by `importlib.import_module(f"tasks.{task}")`. The prefix feels like a fence. It is not much of one: any module under that package is importable, and the import runs its top-level code. **Expression parameters.** Data APIs accept a filter expression and hand it to `eval()` because the result should be "a Python condition". The parameter is declared `expr: str`, which FastAPI validates successfully for any text. **Callables from JSON.** A scheduling endpoint stores `{"func": "module.name", "args": [...]}` and a worker resolves and calls it later. The import happens in another process, long after the request. #### Vulnerable and fixed {#code} ```python import importlib from fastapi import FastAPI from pydantic import BaseModel, ImportString app = FastAPI() class Job(BaseModel): transform: ImportString payload: dict @app.post("/jobs") def create_job(job: Job): return {"result": job.transform(job.payload)} @app.post("/run/{task}") def run(task: str, payload: dict): module = importlib.import_module(f"tasks.{task}") return module.main(payload) ``` Declare the choice as an `Enum`. FastAPI then rejects an unknown name with a 422 before the handler runs, the OpenAPI schema lists the permitted values, and the mapping from name to function is a dictionary in your source: ```python from enum import Enum from fastapi import FastAPI from pydantic import BaseModel import tasks.resize import tasks.summarize app = FastAPI() class TaskName(str, Enum): resize = "resize" summarize = "summarize" TASKS = {TaskName.resize: tasks.resize.main, TaskName.summarize: tasks.summarize.main} class Job(BaseModel): transform: TaskName payload: dict @app.post("/jobs") def create_job(job: Job): return {"result": TASKS[job.transform](job.payload)} @app.post("/run/{task}") def run(task: TaskName, payload: dict): return TASKS[task](payload) ``` #### What FastAPI and Pydantic already do for you {#built-in} The type system is the allowlist mechanism. `Enum` and `Literal` parameters are checked by the framework, documented automatically and impossible to bypass from the client. Pydantic's strict types reject values of the wrong shape. `ImportString` is clearly named and documented as an import; it is safe in a settings class read from your own environment. The work is choosing the narrow type on models that parse request bodies. #### What es-python adds {#es-python} The import triggered by a model field does not appear in your handler, and a reviewer reading the endpoint sees a typed parameter. es-python reports the operation where it happens. An import by a name that came from the request is recorded as `__import__` with the `rce` [impact tag](https://eyalsec.com/docs/events#impact-tags), and the stack trace shows that it ran during body validation. A name passed to `importlib.import_module()` is reported where the import system looks that name up on disk, as a file-system event with the requested module path in it. `eval`, `exec` and `compile` on request text are reported under those names. For the deferred case, where a worker resolves a stored callable name, the value is no longer request data when it is read back; switch on the [db_rows source](https://eyalsec.com/docs/taint-sources#db-rows) so values read from the database are followed too. With data from the [network](https://eyalsec.com/docs/taint-sources#socket) these events are graded Critical. The fixed endpoints import their task modules at startup from names written in source, so there is nothing for es-python to report: the request only ever selects a dictionary key. If you add a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) on `__import__` for the socket source, the vulnerable model fails validation with a `RuntimeError` and the module is never loaded. #### Review checklist {#review} 1. Search request and response models for `ImportString`. 2. Search for `import_module(`, `__import__(` and `eval(` with non-literal arguments. 3. Replace name parameters with `Enum` or `Literal` types. 4. Import plugin modules at startup and dispatch through a dictionary. 5. Review what workers do with stored function names. #### References {#references} - Reference: [Pydantic documentation: Types, ImportString](https://docs.pydantic.dev/latest/api/types/) (checked 2026-10-12) - Reference: [FastAPI documentation: Path Parameters, predefined values](https://fastapi.tiangolo.com/tutorial/path-params/) (checked 2026-10-12) - Reference: [FastAPI documentation: Body, Nested Models](https://fastapi.tiangolo.com/tutorial/body-nested-models/) (checked 2026-10-12) ### Server-side template injection (SSTI) in Django URL: https://eyalsec.com/vulnerabilities/server-side-template-injection/django | Class: https://eyalsec.com/vulnerabilities/server-side-template-injection | Framework: Django Django templates loaded by name from disk are developer code; template injection starts when a view compiles a string that a user wrote, most often an email or notification template stored in the database. #### How it arises in Django {#how} `render(request, "page.html", context)` looks a template up by name through the configured loaders. The user controls the context, never the template. Two APIs take template source instead: - `django.template.Template(template_code)`, whose constructor takes the raw template code. - `Engine.from_string(template_code)`, reached in projects as `engines["django"].from_string(...)`. Both are legitimate. They become an injection point in three recurring situations. **Customisable messages.** A CRM lets each customer edit the body of outgoing emails with placeholders. The body is saved in a `TextField` and compiled with `Template(body)` when a message is sent. No request carries template syntax at send time; the source was planted days earlier by anyone who could edit that record. **Formatting inside the template string.** `Template("Hello " + name + ", your order {{ order.id }} shipped")`. The developer wanted one variable in a hurry. **Preview endpoints.** An editor posts the draft to a view that renders it so the author can see the result. Django's template language is deliberately small. It cannot call functions with arguments or run Python expressions. What it can do is read attributes and dictionary keys of anything in the context and call methods that take no arguments. With a `request` or a `user` object in the context, that reaches settings-derived values, session data and related model rows. The Django documentation says it directly: the template system is not safe against untrusted template authors, who can perform XSS attacks and access properties of template variables that may contain sensitive information. #### Vulnerable and fixed {#code} ```python from django.core.mail import send_mail from django.template import Context, Template from crm.models import Campaign def send_campaign(campaign_id, recipient): campaign = Campaign.objects.get(pk=campaign_id) body = Template(campaign.body).render(Context({"user": recipient, "campaign": campaign})) send_mail(campaign.subject, body, "noreply@example.com", [recipient.email]) ``` If customers only need placeholders, do not give them a template language. `string.Template` substitutes named fields and nothing else, and the mapping lists exactly which values are available: ```python from string import Template from django.core.mail import send_mail from crm.models import Campaign def send_campaign(campaign_id, recipient): campaign = Campaign.objects.get(pk=campaign_id) fields = {"first_name": recipient.first_name, "company": recipient.company} body = Template(campaign.body).safe_substitute(fields) send_mail(campaign.subject, body, "noreply@example.com", [recipient.email]) ``` If real template logic is a product requirement, render with an engine configured for it: no `request` in the context, plain dictionaries of strings instead of model instances, and no custom tags loaded. #### What Django already does for you {#built-in} The default path is safe: templates come from files, output is auto-escaped, and the language has no general expression evaluation. The documentation carries an explicit warning about untrusted template authors. The recommended way to create a `Template` is through the engine's factory methods (`get_template()`, `select_template()` and `from_string()`), which keeps template creation in a few findable places. #### What es-python adds {#es-python} The stored-template case is hard to see from the code or from the request, because the text that matters was saved long before the code that compiles it runs. es-python reports the operation: untrusted text reaching the `Template` constructor is recorded as `ssti django`, tagged `ssti`. Which sources you switch on decides what "untrusted" covers. With the [socket source](https://eyalsec.com/docs/taint-sources#socket), the preview endpoint and the string-concatenation view are reported, graded Critical. With the [db_rows source](https://eyalsec.com/docs/taint-sources#db-rows) on, values read from the database are followed as well, so the campaign body is reported at the moment it is compiled even though it arrived in an earlier request. The event fires whether or not the text contained `{{`. The finding is that someone outside chose template source; today's value being harmless does not change that. When EyalSec can prove the text carried nothing the engine reads as syntax, the event is [suppressed](https://eyalsec.com/docs/suppressed-events). The fixed version never constructs a Django template from the body, so there is no SSTI event at all. [Block network code execution](https://eyalsec.com/docs/rule-templates#block-network-rce) raises on `ssti django`. #### Review checklist {#review} 1. Search for `Template(` and `from_string(` and trace where each argument comes from. 2. List every model field whose content is later compiled as a template. 3. Offer placeholders through `string.Template`, not a template engine. 4. Keep `request`, `user` and model instances out of contexts for user-authored templates. 5. Restrict who can edit stored templates, and log changes. #### References {#references} - Reference: [Django documentation: Templates](https://docs.djangoproject.com/en/5.2/topics/templates/) (checked 2026-10-12) - Reference: [Django documentation: The Django template language for Python programmers](https://docs.djangoproject.com/en/5.2/ref/templates/api/) (checked 2026-10-12) ### Server-side template injection (SSTI) in Flask URL: https://eyalsec.com/vulnerabilities/server-side-template-injection/flask | Class: https://eyalsec.com/vulnerabilities/server-side-template-injection | Framework: Flask Flask exposes Jinja through two functions that look alike, and template injection is the result of using `render_template_string()` with a string that already has request data inside it. #### How it arises in Flask {#how} `render_template(name, **context)` loads a file from the `templates` folder. `render_template_string(source, **context)` takes, in the words of the API reference, the source code of the template to render. The first argument is code in both cases. Only in the second can a view build that code at runtime. Where it actually shows up: **Custom error handlers.** A 404 handler that wants to show the missing URL writes it into the page with an f-string. `request.path` is attacker-chosen and needs no authentication, no form and no valid route, so this is frequently the first SSTI a scanner finds. **Single-file apps.** Projects without a `templates` directory keep HTML in Python strings. Adding a variable by string formatting, not by context, is one keystroke away. **Flash and status messages.** `render_template_string("

" + message + "

")`, where `message` came from a query parameter after a redirect. **User-designed content.** Invoice layouts or email bodies that customers edit, rendered with the app's main Jinja environment. Jinja expressions are far more capable than Django's. An expression can read attributes, index, and call. From any object in reach, an attribute walk leads to Python internals and from there to running code on the server. Flask also adds `request`, `session`, `g` and `config` to every template context, so an injected `{{ config }}` discloses the secret key before any escalation. #### Vulnerable and fixed {#code} ```python from flask import Flask, render_template_string, request app = Flask(__name__) @app.errorhandler(404) def not_found(error): page = f"

Not found

No page at {request.path}

" return render_template_string(page), 404 ``` Keep the source constant and pass the path as a variable. Jinja then treats it as data, and Flask's autoescaping makes it safe to display as well: ```python from flask import Flask, render_template_string, request app = Flask(__name__) NOT_FOUND = "

Not found

No page at {{ path }}

" @app.errorhandler(404) def not_found(error): return render_template_string(NOT_FOUND, path=request.path), 404 ``` #### What Flask and Jinja already do for you {#built-in} Flask configures Jinja to escape values automatically, which is why passing data through the context is safe for HTML as well as for the template. Jinja ships `SandboxedEnvironment` for templates written by users: it prevents access to private and internal attributes and raises `SecurityError` on unsafe access. Its documentation is careful about the limits. The sandbox alone is not a solution for perfect security, templates can still consume large amounts of CPU and memory, and you should pass only the data the template needs. `ImmutableSandboxedEnvironment` additionally stops templates from modifying lists and dictionaries. #### What es-python adds {#es-python} es-python reports at the call that turns text into a template. For Flask that is one place: `render_template_string()` and `jinja2.Template` both go through `Environment.from_string`, and untrusted text arriving there is recorded as `ssti jinja2`, tagged `ssti` and graded Critical for data from the [network](https://eyalsec.com/docs/taint-sources#socket). The trace names the error handler or view that built the string. Two behaviours are worth knowing before you read the events. First, the check is on the template source. The fixed handler passes `request.path` in the context, so nothing is reported; values rendered through `{{ }}` are data. Second, the event does not wait for a payload. A request for `/missing` with no braces in it still shows that the path flows into template source, so you find the bug from ordinary traffic. When EyalSec can prove the text held nothing Jinja would read as syntax, the event is [suppressed](https://eyalsec.com/docs/suppressed-events) and stays out of the default view. User-authored templates rendered in a `SandboxedEnvironment` are still reported, because an outside author is still writing template code; if that is a product feature, mark that call site with a [rule](https://eyalsec.com/docs/rules). The [Block network code execution](https://eyalsec.com/docs/rule-templates#block-network-rce) template, or any [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) on `ssti jinja2`, makes the view fail with a `RuntimeError` instead of rendering. #### Review checklist {#review} 1. Search for `render_template_string(` and check that the first argument is a constant. 2. Read every `errorhandler` function. 3. Move inline HTML with variables into template files. 4. Render customer-authored templates in a sandboxed environment with a minimal context. 5. Never place `config`, `request` or model objects in a context for user templates. #### References {#references} - Reference: [Flask documentation: API, render_template_string()](https://flask.palletsprojects.com/en/stable/api/) (checked 2026-10-12) - Reference: [Flask documentation: Security Considerations, Cross-Site Scripting](https://flask.palletsprojects.com/en/stable/web-security/) (checked 2026-10-12) - Reference: [Jinja documentation: Sandbox](https://jinja.palletsprojects.com/en/stable/sandbox/) (checked 2026-10-12) ### Server-side template injection (SSTI) in FastAPI URL: https://eyalsec.com/vulnerabilities/server-side-template-injection/fastapi | Class: https://eyalsec.com/vulnerabilities/server-side-template-injection | Framework: FastAPI FastAPI's documented template path renders files by name, so template injection in a FastAPI service nearly always sits in a secondary feature (emails, PDFs, chat prompts) that compiles a string with Jinja directly. #### How it arises in FastAPI {#how} The documented pattern is `templates = Jinja2Templates(directory="templates")` and `templates.TemplateResponse(request=request, name="item.html", context={"id": id})`. The request supplies context values. The template is a file the team wrote. An API that only does this has no SSTI. Most FastAPI services are JSON APIs, though, and their templates are not pages: **Notification bodies.** A `POST /notifications` endpoint accepts `{"template": "Hi {{ name }}", "vars": {...}}` so that callers can format their own messages. The handler runs `jinja2.Template(body.template).render(**body.vars)`. **Per-tenant templates.** Tenants store invoice or email layouts; a background task loads the text and renders it with `templates.env.from_string(...)`, reusing the web environment and all of its globals. **Prompt and document builders.** Services that assemble a text prompt or a Markdown report from parts use Jinja for loops and conditionals, and one of the parts is a user-written instruction block concatenated into the source. **Templated SQL or shell.** Jinja used to build a query or a command from a request-supplied snippet is two vulnerability classes at once. Two properties make the direct route worse than it looks. A bare `jinja2.Template(...)` or `Environment()` is not sandboxed, and Jinja expressions can walk attributes from any value to Python's internals, which ends in code execution. And validation gives false comfort: `template: str` on a Pydantic model accepts any text of any shape. #### Vulnerable and fixed {#code} ```python from fastapi import FastAPI from jinja2 import Template from pydantic import BaseModel app = FastAPI() class Notification(BaseModel): template: str variables: dict @app.post("/notifications/preview") def preview(note: Notification): return {"text": Template(note.template).render(**note.variables)} ``` Let callers pick a template you wrote and supply only the values. The identifier is an `Enum`, the sources are constants, and the variables are a typed model, so a caller cannot add names to the context: ```python from enum import Enum from fastapi import FastAPI from jinja2.sandbox import ImmutableSandboxedEnvironment from pydantic import BaseModel app = FastAPI() env = ImmutableSandboxedEnvironment(autoescape=True) class Kind(str, Enum): welcome = "welcome" receipt = "receipt" SOURCES = { Kind.welcome: env.from_string("Hi {{ name }}, welcome to {{ product }}."), Kind.receipt: env.from_string("Hi {{ name }}, we received {{ amount }}."), } class Variables(BaseModel): name: str = "" product: str = "" amount: str = "" class Notification(BaseModel): kind: Kind variables: Variables @app.post("/notifications/preview") def preview(note: Notification): return {"text": SOURCES[note.kind].render(**note.variables.model_dump())} ``` #### What FastAPI and Jinja already do for you {#built-in} `Jinja2Templates` resolves names inside one directory and renders files, which keeps request data in the context. Pydantic lets you type the variables so the context is a closed set. Jinja provides `SandboxedEnvironment` and `ImmutableSandboxedEnvironment` for templates you do not fully control, and documents their limits honestly: the sandbox alone is not a solution for perfect security, and a small template can still render a very large output. #### What es-python adds {#es-python} In an API service the template call is rarely in the handler. It is in a mailer module, a task, or a utility that several endpoints share, and the string passes through a Pydantic model and often a queue on the way. es-python follows the request text through those hops and reports when it is compiled: `ssti jinja2`, recorded at `Environment.from_string`, which `jinja2.Template` uses as well. The event carries the `ssti` [impact tag](https://eyalsec.com/docs/events#impact-tags), the offending text and the trace of the function that compiled it. For the per-tenant case, the text comes from your own database at render time. Turn on the [db_rows source](https://eyalsec.com/docs/taint-sources#db-rows) alongside [socket](https://eyalsec.com/docs/taint-sources#socket) and that flow is reported too. The fixed endpoint compiles its templates once at import, from literals, so no request text reaches the compiler and no SSTI event is produced; the values rendered through `{{ name }}` are data. If you keep a sandboxed renderer for tenant-written templates, expect the event to remain, since an outside author still writes template code, and record that decision with a [rule](https://eyalsec.com/docs/rules). [Block network code execution](https://eyalsec.com/docs/rule-templates#block-network-rce) raises a `RuntimeError` at the compile. #### Review checklist {#review} 1. Search for `jinja2.Template(`, `Template(` and `.from_string(` outside module scope. 2. Check mailers, PDF builders and prompt builders, not only HTML responses. 3. Replace free-form template fields with an `Enum` of templates plus typed variables. 4. Use a separate sandboxed environment, with no globals, for tenant-authored templates. 5. Cap output size and render time. #### References {#references} - Reference: [FastAPI documentation: Templates](https://fastapi.tiangolo.com/advanced/templates/) (checked 2026-10-12) - Reference: [Jinja documentation: Sandbox](https://jinja.palletsprojects.com/en/stable/sandbox/) (checked 2026-10-12) - Reference: [Jinja documentation: API, Environment.from_string()](https://jinja.palletsprojects.com/en/stable/api/) (checked 2026-10-12) ### Insecure deserialization in Django URL: https://eyalsec.com/vulnerabilities/insecure-deserialization/django | Class: https://eyalsec.com/vulnerabilities/insecure-deserialization | Framework: Django A typical Django view never calls `pickle.loads()`, yet pickle is running under most Django sites, in the cache framework and sometimes in the task queue, so the question is who can write to those stores. #### How it arises in Django {#how} **The cache framework.** Django's cache documentation says you can cache any Python object that can be pickled safely. Values are pickled on `cache.set()` and unpickled on `cache.get()`. That makes the cache server part of your code-execution boundary. For the file-based backend the documentation spells out the consequence: an attacker who gains access to the cache file can not only falsify HTML content but also remotely execute arbitrary code, as the data is serialized using pickle. The same reasoning applies to a Memcached or Redis instance that another application, another tenant or an SSRF bug can write to. **Sessions on old settings.** `SESSION_SERIALIZER` defaults to `JSONSerializer`. Older projects set `PickleSerializer` to store richer objects. Django deprecated it in 4.1 because of the risk of remote code execution and removed it in 5.0. With signed-cookie sessions and a leaked `SECRET_KEY`, the pickle serializer turned a forged cookie into code execution. A project that carries its own copy of that serializer forward keeps the problem. **Celery.** Celery's default serializer has been JSON since version 4.0, and by default only JSON is accepted. Projects that pass model instances or datetimes as task arguments switch to pickle and add it to `accept_content`. From then on, anyone who can publish to the broker can run code in the worker. **Hand-rolled uses.** A "remember my filters" cookie, an export token, or a `BinaryField` that stores `pickle.dumps(obj)` and is loaded back on every request. #### Vulnerable and fixed {#code} ```python import base64 import pickle from django.http import JsonResponse CELERY_TASK_SERIALIZER = "pickle" CELERY_ACCEPT_CONTENT = ["pickle", "json"] def saved_filters(request): raw = request.COOKIES.get("filters", "") filters = pickle.loads(base64.b64decode(raw)) if raw else {} return JsonResponse({"filters": filters}) ``` Django's signing module is made for this job. `signing.loads()` verifies the signature and uses JSON underneath, so even a stolen key does not turn the cookie into code. The queue goes back to JSON, and tasks receive primary keys: ```python from django.core import signing from django.http import JsonResponse CELERY_TASK_SERIALIZER = "json" CELERY_ACCEPT_CONTENT = ["json"] def saved_filters(request): raw = request.COOKIES.get("filters", "") try: filters = signing.loads(raw, salt="filters", max_age=30 * 86400) if raw else {} except signing.BadSignature: filters = {} return JsonResponse({"filters": filters}) ``` #### What Django already does for you {#built-in} Django moved its defaults away from pickle wherever users are involved. Sessions serialize as JSON, and the pickle serializer is gone. The signing documentation explains that `dumps()` and `loads()` use JSON so that an attacker who steals `SECRET_KEY` cannot execute arbitrary commands by exploiting the pickle format. The cache documentation warns about cache locations inside `MEDIA_ROOT` or `STATIC_ROOT`. The remaining decision, which networks can reach the cache and the broker, belongs to the deployment. #### What es-python adds {#es-python} es-python reports bytes from an untrusted source reaching a deserializer, at the call: `pickle loads` here. The dashboard puts the `rce` [impact tag](https://eyalsec.com/docs/events#impact-tags) on it, because unpickling attacker bytes is code execution. For the cookie view that is unambiguous: request data, Critical. The cache is the instructive case. With the [socket source](https://eyalsec.com/docs/taint-sources#socket) on, values Django reads back from Memcached or Redis arrived over a network connection, so unpickling them is reported too. That is not a false alarm. It is an accurate statement that your process executes whatever that server returns. Read those events once as an inventory: which code paths unpickle, from which host. If the cache is on a private network that only this application can write to, record that with a [rule](https://eyalsec.com/docs/rules) for that call site. If the answer is "other things can write there", you have found the real issue. Deserialization is also the class EyalSec never [suppresses](https://eyalsec.com/docs/suppressed-events) based on the value, because a serialized blob has no harmless form. For workers, broker messages arrive over a socket, so with the socket source on `pickle loads` and `yaml unsafe load` on a message are reported too. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise), `pickle.loads()` raises a `RuntimeError` before the bytes are processed. #### Review checklist {#review} 1. Search settings for `PickleSerializer`, `"pickle"` and `accept_content`. 2. Search code for `pickle.loads(`, `pickle.load(` and `yaml.load(`. 3. Put the cache and the broker on a network only the application can reach, with authentication. 4. Keep the file-based cache directory outside any served or uploadable path. 5. Pass identifiers to tasks, not objects. #### References {#references} - Reference: [Django documentation: Django's cache framework](https://docs.djangoproject.com/en/5.2/topics/cache/) (checked 2026-10-12) - Reference: [Django documentation: Cryptographic signing](https://docs.djangoproject.com/en/5.2/topics/signing/) (checked 2026-10-12) - Reference: [Django 4.1 release notes: PickleSerializer deprecated](https://docs.djangoproject.com/en/5.2/releases/4.1/) and [Django 5.0 release notes: removed](https://docs.djangoproject.com/en/5.2/releases/5.0/) (checked 2026-10-12) - Reference: [Celery documentation: Security, serializers](https://docs.celeryq.dev/en/stable/userguide/security.html) (checked 2026-10-12) ### Insecure deserialization in Flask URL: https://eyalsec.com/vulnerabilities/insecure-deserialization/flask | Class: https://eyalsec.com/vulnerabilities/insecure-deserialization | Framework: Flask Flask itself does not unpickle anything a client sends, so every unsafe deserialization in a Flask application was added above the framework: a YAML import endpoint, an object stored in a cookie, or a cache of pickled results. #### How it arises in Flask {#how} Start with what is not a problem. Flask's default session is `SecureCookieSessionInterface`, which stores the session in a signed cookie through the `itsdangerous` module, and its serializer is a compact JSON-derived format with support for a few extra Python types. A client can read that cookie. Without the secret key a client cannot change it, and even with the key it is parsed as JSON, not as pickle. The findings are elsewhere. **YAML import endpoints.** "Upload your config", "import a pipeline definition", "paste a Kubernetes manifest". The handler calls `yaml.load(request.data, Loader=yaml.Loader)` or `yaml.unsafe_load()`. PyYAML's documentation warns that it is not safe to call `yaml.load` with data from an untrusted source, and that it is as powerful as `pickle.load`. **Objects in cookies or hidden fields.** Wizard-style forms keep state between steps by pickling a Python object, base64-encoding it and sending it to the browser. It comes back on the next request and is loaded. **Pickled values in a shared store.** A view caches a computed object in Redis with `pickle.dumps()` and loads it on the next hit. Whoever can write to that Redis key controls what the web process unpickles. **Model and data files.** Small ML demos built on Flask accept a model file upload and load it with a pickle-based loader. Python's documentation for `pickle` is unambiguous: the module is not secure, and malicious pickle data can execute arbitrary code during unpickling. #### Vulnerable and fixed {#code} ```python import base64 import pickle import yaml from flask import Flask, jsonify, request app = Flask(__name__) @app.post("/pipelines/import") def import_pipeline(): spec = yaml.load(request.get_data(), Loader=yaml.Loader) return jsonify(name=spec["name"], steps=len(spec["steps"])) @app.post("/wizard/next") def wizard_next(): state = pickle.loads(base64.b64decode(request.form["state"])) state["step"] += 1 return jsonify(state=base64.b64encode(pickle.dumps(state)).decode()) ``` `yaml.safe_load()` builds only plain scalars, lists and dictionaries. The wizard state belongs in Flask's session, which is already signed and already JSON: ```python import yaml from flask import Flask, abort, jsonify, request, session app = Flask(__name__) app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024 @app.post("/pipelines/import") def import_pipeline(): spec = yaml.safe_load(request.get_data()) if not isinstance(spec, dict) or not isinstance(spec.get("steps"), list): abort(400) return jsonify(name=str(spec.get("name", "")), steps=len(spec["steps"])) @app.post("/wizard/next") def wizard_next(): state = session.get("wizard", {"step": 0}) state["step"] += 1 session["wizard"] = state return jsonify(step=state["step"]) ``` #### What Flask already does for you {#built-in} The session design is the main protection: signed, JSON-based and on by default, so the obvious place to keep per-user state is also the safe one. `request.get_json()` parses JSON and nothing more. `MAX_CONTENT_LENGTH` bounds how much data a request can make the app read. Flask ships no helper that unpickles request data, which is why a search for `pickle` and `yaml.load` in a Flask codebase finds every candidate. #### What es-python adds {#es-python} The label depends on the loader. An unsafe PyYAML loader fed with request bytes is reported as `yaml unsafe load`; the pickle cookie as `pickle loads`; `json.loads()` with an `object_hook` as `object hook`; `jsonpickle` as `unsafe deserialization`. The YAML and `jsonpickle` events carry the `unsafe deserialization` [impact tag](https://eyalsec.com/docs/events#impact-tags), the pickle events carry `rce`, and data from the [network](https://eyalsec.com/docs/taint-sources#socket) is graded Critical. Correct code is quiet, which keeps the list short. `yaml.safe_load()` and a plain `json.loads()` are not treated as deserialization sinks, and Flask's session handling does not unpickle, so the fixed endpoints above produce no event in this class. What remains is exactly the set of places where bytes from outside reach a loader that can construct arbitrary objects. EyalSec does not [suppress](https://eyalsec.com/docs/suppressed-events) these on the value, since no encoding makes a pickle harmless. For the shared-store case, the bytes are read from Redis over a socket, so with the socket source on those `pickle loads` events are reported as well, and they tell you which views trust that store. The [Block network code execution](https://eyalsec.com/docs/rule-templates#block-network-rce) template raises on `yaml unsafe load` and `unsafe deserialization`; a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) on `pickle loads` stops the cookie case with a `RuntimeError` before the payload runs. #### Review checklist {#review} 1. Search for `yaml.load(`, `yaml.unsafe_load(`, `pickle.loads(` and `pickle.load(`. 2. Replace YAML loading with `yaml.safe_load()` and validate the result's shape. 3. Keep per-user state in `session`, or server-side keyed by a session id. 4. Store JSON, not pickles, in shared caches. 5. Rotate `SECRET_KEY` if it has ever been committed. #### References {#references} - Reference: [Flask documentation: API, SecureCookieSessionInterface](https://flask.palletsprojects.com/en/stable/api/) (checked 2026-10-12) - Reference: [Flask documentation: Security Considerations, resource use](https://flask.palletsprojects.com/en/stable/web-security/) (checked 2026-10-12) - Reference: [PyYAML documentation: Loading YAML](https://pyyaml.org/wiki/PyYAMLDocumentation) (checked 2026-10-12) - Reference: [Python documentation: pickle](https://docs.python.org/3/library/pickle.html) (checked 2026-10-12) ### Insecure deserialization in FastAPI URL: https://eyalsec.com/vulnerabilities/insecure-deserialization/fastapi | Class: https://eyalsec.com/vulnerabilities/insecure-deserialization | Framework: FastAPI FastAPI turns JSON into validated Pydantic models and never constructs arbitrary objects from a request, so the deserialization risk in a FastAPI service is concentrated where it stops using that path: file uploads and queues. #### How it arises in FastAPI {#how} **Model uploads in ML services.** FastAPI is a common front for inference. An admin-looking endpoint accepts a new model as `UploadFile` and loads it. Many model and array formats are pickle underneath, and `pickle.load(upload.file)` on a client's file is code execution with an `application/octet-stream` content type. The Python documentation states it plainly: the `pickle` module is not secure, and you should only unpickle data you trust. **Raw-body endpoints.** A handler reads `await request.body()` to accept "any Python object" from an internal client, bypassing Pydantic on purpose for speed or flexibility. Internal clients forward user data. **Task queues.** Long work moves to Celery or a similar worker. Celery accepts only JSON by default; a service that needs to pass dataframes or numpy arrays enables the pickle serializer, and from then on the broker is a way to run code in the worker. **Result caches.** An endpoint memoises an expensive response by pickling it into Redis and loading it on the next request. **YAML configuration endpoints.** Rule engines and workflow APIs accept YAML and load it with a full loader. A subtle point about `Depends`: authentication usually runs as a dependency, and it is easy to protect the JSON routes and forget the one binary upload route registered on a different router. #### Vulnerable and fixed {#code} ```python import pickle from fastapi import FastAPI, UploadFile app = FastAPI() MODELS = {} @app.post("/models/{name}") async def upload_model(name: str, file: UploadFile): MODELS[name] = pickle.load(file.file) return {"loaded": name} ``` Do not load uploaded files as pickles at all. Accept weights in a data-only format, verify the artifact against a digest you published through a separate channel, and keep the object construction in your own code. The example below takes JSON weights and checks a SHA-256 allowlist before parsing: ```python import hashlib import json from fastapi import FastAPI, HTTPException, UploadFile app = FastAPI() MODELS = {} APPROVED_SHA256 = {"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"} @app.post("/models/{name}") async def upload_model(name: str, file: UploadFile): blob = await file.read() if hashlib.sha256(blob).hexdigest() not in APPROVED_SHA256: raise HTTPException(status_code=400, detail="artifact is not approved") weights = json.loads(blob) if not isinstance(weights, dict): raise HTTPException(status_code=400, detail="unexpected format") MODELS[name] = weights return {"loaded": name} ``` If a pickle-based format is unavoidable, load it only from storage that the service account cannot write to and that a release pipeline, not an API, populates. #### What FastAPI already does for you {#built-in} Request bodies declared as Pydantic models are parsed as JSON and validated field by field; no class is instantiated because the client named it. `UploadFile` gives you a file object and metadata without interpreting the content. Dependencies make it straightforward to require authentication on a whole router. Celery, commonly paired with FastAPI, defaults to JSON and rejects other content types unless you opt in through `accept_content`. #### What es-python adds {#es-python} Uploaded bytes reach the service over the connection and pass through the multipart parser before the loader reads them. A small upload stays in memory on the way and es-python follows it to the loader. A large one is spooled to a temporary file first, so switch on the [file source](https://eyalsec.com/docs/taint-sources#file) as well and bytes read back from disk are followed too. When the data reaches the deserializer es-python records `pickle load` for a file-object load or `pickle loads` for a bytes load, tagged `rce` in the [events list](https://eyalsec.com/docs/events#impact-tags) because a pickle can run code as it loads, with the trace of the endpoint. Request data is graded Critical. The fixed endpoint calls `json.loads()` with no hook, which is not a deserialization sink, so it reports nothing in this class. The file source also covers model files that arrive by other routes: a pickle read from a download directory or a shared volume is then reported when it is loaded, so unsafe model loading stays visible. Two things to expect. If you run a pickle-serializing worker, messages from the broker arrive over a socket and each unpickle is reported; that is the accurate picture of what the worker trusts. And because any byte of a pickle can be an instruction, EyalSec does not [suppress](https://eyalsec.com/docs/suppressed-events) these events on the value. With a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) on `pickle loads`, the load raises a `RuntimeError` before the payload is processed. #### Review checklist {#review} 1. Search for `pickle.load`, `pickle.loads`, `yaml.load(` and loaders built on pickle. 2. List every endpoint that takes `UploadFile`, `bytes` or `Request` instead of a model. 3. Confirm those routes have the same auth dependencies as the JSON routes. 4. Keep `accept_content = ["json"]` on workers. 5. Load model artifacts from read-only, release-managed storage. #### References {#references} - Reference: [FastAPI documentation: Request Files](https://fastapi.tiangolo.com/tutorial/request-files/) (checked 2026-10-12) - Reference: [FastAPI documentation: Dependencies](https://fastapi.tiangolo.com/tutorial/dependencies/) (checked 2026-10-12) - Reference: [Python documentation: pickle](https://docs.python.org/3/library/pickle.html) (checked 2026-10-12) - Reference: [Celery documentation: Calling Tasks, serializers](https://docs.celeryq.dev/en/stable/userguide/calling.html) (checked 2026-10-12) ### Path traversal in Django URL: https://eyalsec.com/vulnerabilities/path-traversal/django | Class: https://eyalsec.com/vulnerabilities/path-traversal | Framework: Django Django's storage API handles upload names for you, so path traversal in a Django project usually comes from a download or export view that builds a filesystem path by hand and returns it with `FileResponse`. #### How it arises in Django {#how} **Download views.** Media that needs a permission check cannot be served by the web server directly, so a view does it: check the user, join `MEDIA_ROOT` with a name from the URL, open, return `FileResponse`. The check is about the user. Nothing checks the name. **The `path` converter.** In a URLconf, `` matches one segment. `` matches any non-empty string including the path separator, so that a complete path can be captured. It is the right converter for nested media and it delivers slashes to the view by design. **`os.path.join` with an absolute component.** If the second argument starts with `/`, the result is that argument alone: `os.path.join("/srv/media", "/etc/passwd")` is `/etc/passwd`. A view that strips `..` and forgets this is still open. **`django.views.static.serve` outside development.** Django documents that this view is not hardened for production use and should be used only as a development aid. It gets wired into production URLconfs to "temporarily" serve `MEDIA_ROOT`. **Upload names used directly.** `UploadedFile.name` is the client's file name. Saving through a `FileField` or a storage's `save()` is safe; writing `open(os.path.join(root, f.name), "wb")` yourself is not. **Archive imports.** Admin tools that accept a zip of fixtures or theme files and extract every member. #### Vulnerable and fixed {#code} URLconf entry: `path("reports/", views.report)`. ```python import os from django.conf import settings from django.contrib.auth.decorators import login_required from django.http import FileResponse @login_required def report(request, name): full = os.path.join(settings.MEDIA_ROOT, "reports", name) return FileResponse(open(full, "rb"), as_attachment=True) ``` Resolve the final path, symlinks included, and require it to stay under the base directory. Better still, look the file up through a model so the request carries an id, not a path: ```python from pathlib import Path from django.conf import settings from django.contrib.auth.decorators import login_required from django.http import FileResponse, Http404 @login_required def report(request, name): base = (Path(settings.MEDIA_ROOT) / "reports").resolve() full = (base / name).resolve() if not full.is_relative_to(base) or not full.is_file(): raise Http404("no such report") return FileResponse(full.open("rb"), as_attachment=True) ``` #### What Django already does for you {#built-in} The storage layer is careful. `Storage.get_valid_name()` returns a file name suitable for the target storage, `generate_filename()` validates it, and `FileField.upload_to` keeps uploads in a directory you chose. Django raises `SuspiciousFileOperation` when file operations look wrong. The `str` converter excludes slashes. And the documentation is explicit that `static.serve` is a development aid. Stay on those rails for uploads, and the remaining risk is custom read paths. #### What es-python adds {#es-python} es-python reports when a path that came from the request reaches a file operation. Two events fire together on a hit. The base event names the operation, `io open` for `open()`. Beside it, a `path traversal` event fires when the request-supplied path contains a `..` segment or is absolute, and that one carries the `path traversal` [impact tag](https://eyalsec.com/docs/events#impact-tags) and is graded Critical for data from the [network](https://eyalsec.com/docs/taint-sources#socket). That split is useful on a download view. Ordinary traffic produces `io open` events, showing that a request chooses the file at all, which is worth knowing by itself. A probe with `../` or a leading slash produces `path traversal`, and the event shows the exact value and the view. On the fixed view, a traversal attempt is rejected before any `open()`, so the sharper event does not appear. Some limits, so you can read the events correctly. Matching is byte-level and POSIX-only: it looks for `/` separators and does not decode percent-encoding itself. Django decodes the URL before your view sees it, and the check runs on the decoded value your code actually passes to `open()`. For archive imports, the raw member name is checked before extraction and reported as `zip slip` or `tar slip`. The [Block backend network attacks](https://eyalsec.com/docs/rule-templates#block-backend) template, or any [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) on `path traversal`, raises a `RuntimeError` before the file is opened. #### Review checklist {#review} 1. Search for `FileResponse(`, `open(` and `os.path.join(` in views. 2. Check every `` converter and what its view does with the value. 3. Remove `django.views.static.serve` from production URLconfs. 4. Save uploads through `FileField` or the storage API only. 5. Prefer database ids over file names in URLs. #### References {#references} - Reference: [Django documentation: URL dispatcher, path converters](https://docs.djangoproject.com/en/5.2/topics/http/urls/) (checked 2026-10-12) - Reference: [Django documentation: Built-in views, static.serve](https://docs.djangoproject.com/en/5.2/ref/views/) (checked 2026-10-12) - Reference: [Django documentation: The File storage API](https://docs.djangoproject.com/en/5.2/ref/files/storage/) (checked 2026-10-12) - Reference: [Django documentation: Request and response objects, FileResponse](https://docs.djangoproject.com/en/5.2/ref/request-response/) (checked 2026-10-12) ### Path traversal in Flask URL: https://eyalsec.com/vulnerabilities/path-traversal/flask | Class: https://eyalsec.com/vulnerabilities/path-traversal | Framework: Flask Flask gives you one file-serving function that checks the path and one that does not, and path traversal in Flask is mostly a matter of which of the two a view calls. #### How it arises in Flask {#how} **`send_file()` with a path you built.** `flask.send_file(path_or_file)` sends the contents of the file it is given. It is the right tool for a file your code chose. Views join an upload folder with a request value and pass the result in; `send_file` has no base directory to compare against. **The `path` converter.** `@app.route("/files/")` accepts slashes. Flask's own example for serving uploads uses exactly this route, together with `send_from_directory()`. **A client-chosen directory.** `send_from_directory(directory, path)` is documented as a secure way to serve files from a folder. The same documentation adds a condition: `directory` must not be a value provided by the client, otherwise it becomes insecure. Multi-tenant apps that build the directory from a tenant name in the URL break that condition. **Upload names.** `request.files["file"].filename` is sent by the client. Flask's upload pattern says to always pass it through `secure_filename()` before joining it to the upload folder. Handlers that keep the original name "so users recognise their files" skip it. **Two join traps.** `os.path.join()` discards everything before an absolute component. And `werkzeug.security.safe_join()` returns `None` when the path is unsafe; code that does not test for `None` fails later with a confusing error, and gets "fixed" by going back to `os.path.join`. #### Vulnerable and fixed {#code} ```python import os from flask import Flask, request, send_file app = Flask(__name__) app.config["UPLOAD_FOLDER"] = "/srv/app/uploads" @app.get("/files/") def download(name): return send_file(os.path.join(app.config["UPLOAD_FOLDER"], name)) @app.post("/files") def upload(): f = request.files["file"] f.save(os.path.join(app.config["UPLOAD_FOLDER"], f.filename)) return {"saved": f.filename} ``` `send_from_directory()` uses `safe_join()` internally and raises a 404 when the result leaves the directory or is not a regular file. `secure_filename()` reduces the upload name to ASCII letters, digits and `._-`, and can return an empty string, which must be handled: ```python import os from flask import Flask, abort, request, send_from_directory from werkzeug.utils import secure_filename app = Flask(__name__) app.config["UPLOAD_FOLDER"] = "/srv/app/uploads" @app.get("/files/") def download(name): return send_from_directory(app.config["UPLOAD_FOLDER"], name, as_attachment=True) @app.post("/files") def upload(): f = request.files["file"] name = secure_filename(f.filename or "") if not name: abort(400) f.save(os.path.join(app.config["UPLOAD_FOLDER"], name)) return {"saved": name} ``` #### What Flask and Werkzeug already do for you {#built-in} Most of the work, if you use them. `send_from_directory()` exists for this exact case and its documentation states the guarantee and its condition. `safe_join()` joins untrusted components to a trusted base and refuses to escape it. `secure_filename()` makes a name safe for a regular filesystem and for `os.path.join()`. The default `string` converter does not match slashes. Flask's upload guide shows all of these together in a dozen lines. #### What es-python adds {#es-python} Flask apps tend to grow file handling in several places: a download route, an export, a thumbnailer, an admin log viewer. es-python gives you the list from running traffic. Each time a request-supplied path reaches a file operation it records the base event, `io open` for `open()`, with the path and the view. When that path contains a `..` segment or is absolute, a `path traversal` event fires beside it, tagged `path traversal` and graded Critical for [network](https://eyalsec.com/docs/taint-sources#socket) data. An embedded NUL byte is reported as `null byte injection`. Used with the fixed code, the picture changes in a specific way. `send_from_directory()` refuses an escaping path before opening anything, so a probe yields a 404 and no `path traversal` event at an open. A legitimate download still shows an `io open`, at a lower grade, because the request did pick the file. EyalSec also recognises `secure_filename()`: an event for a path built from its output is still shown, with its [severity capped](https://eyalsec.com/docs/suppressed-events#severity-caps). Writes have their own treatment. An upload saved to disk is a disclosure question, so es-python records where the data was written and whether other users on the machine can read that file. For blocking, a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) on `path traversal` raises a `RuntimeError` before the file is opened, and the [Block backend network attacks](https://eyalsec.com/docs/rule-templates#block-backend) template includes one. #### Review checklist {#review} 1. Search for `send_file(` and confirm no argument derives from a request. 2. Use `send_from_directory()` with a constant directory. 3. Call `secure_filename()` on every upload name and handle the empty result. 4. Test `safe_join()` results for `None`. 5. Generate storage names yourself (a UUID) and keep the original name in the database. #### References {#references} - Reference: [Flask documentation: API, send_from_directory() and send_file()](https://flask.palletsprojects.com/en/stable/api/) (checked 2026-10-12) - Reference: [Flask documentation: Uploading Files](https://flask.palletsprojects.com/en/stable/patterns/fileuploads/) (checked 2026-10-12) - Reference: [Werkzeug documentation: Utilities, secure_filename() and safe_join()](https://werkzeug.palletsprojects.com/en/stable/utils/) (checked 2026-10-12) ### Path traversal in FastAPI URL: https://eyalsec.com/vulnerabilities/path-traversal/fastapi | Class: https://eyalsec.com/vulnerabilities/path-traversal | Framework: FastAPI FastAPI will happily deliver a full file path to your function as a validated `str`, so a file endpoint built from the tutorial's `{file_path:path}` parameter and a `FileResponse` needs its own containment check. #### How it arises in FastAPI {#how} **The path convertor.** FastAPI's path-parameter tutorial shows `/files/{file_path:path}`: the `:path` suffix tells the router the parameter should match any path, slashes included. The example returns the value. Real endpoints open it. **`FileResponse` takes a path.** `FileResponse(path)` asynchronously streams that file. Like any response class it trusts its caller. There is no directory argument and nothing to compare the path against. **`UploadFile.filename`.** The upload tutorial returns `{"filename": file.filename}`. That attribute is the client's file name, unmodified. Writing to `UPLOAD_DIR / file.filename` lets the client choose where the bytes land, and `pathlib` has the same absolute-path behaviour as `os.path.join`: `Path("/srv/up") / "/etc/cron.d/x"` is `/etc/cron.d/x`. **Query parameters for exports.** `GET /export?file=2026-10.csv` declared as `file: str`. Type validation passes for any string. **Type validation is orthogonal.** A `str` path parameter is valid whatever it contains. Reviewers who rely on "FastAPI validates inputs" often skip file endpoints for that reason. **Dataset and model stores.** ML services read `datasets/{name}` or `models/{version}` from disk, with the name taken from a request body field. #### Vulnerable and fixed {#code} ```python from pathlib import Path from fastapi import FastAPI, UploadFile from fastapi.responses import FileResponse app = FastAPI() STORE = Path("/srv/app/store") @app.get("/files/{file_path:path}") def download(file_path: str): return FileResponse(STORE / file_path) @app.post("/files") async def upload(file: UploadFile): (STORE / file.filename).write_bytes(await file.read()) return {"saved": file.filename} ``` For downloads, resolve the candidate and require it to stay inside the store. For uploads, do not use the client's name as a path at all: generate one, and return it: ```python import uuid from pathlib import Path from fastapi import FastAPI, HTTPException, UploadFile from fastapi.responses import FileResponse app = FastAPI() STORE = Path("/srv/app/store").resolve() @app.get("/files/{file_path:path}") def download(file_path: str): target = (STORE / file_path).resolve() if not target.is_relative_to(STORE) or not target.is_file(): raise HTTPException(status_code=404) return FileResponse(target) @app.post("/files") async def upload(file: UploadFile): name = uuid.uuid4().hex (STORE / name).write_bytes(await file.read()) return {"saved": name, "original": file.filename} ``` If the files are public and need no per-request logic, skip the endpoint: `app.mount("/static", StaticFiles(directory="static"), name="static")`. #### What FastAPI and Starlette already do for you {#built-in} `StaticFiles` serves one directory and does the containment work itself; its `follow_symlink` option defaults to `False`, so a symbolic link inside the directory does not become a way out. Ordinary path parameters (without `:path`) match a single segment. `UploadFile` keeps content in a spooled file and never writes to a client-chosen location by its own action. Typed parameters let you replace names with integers or UUIDs, which removes the path from the request entirely. #### What es-python adds {#es-python} What a FastAPI file endpoint looks like under es-python: every request that reaches `FileResponse` or `open()` with a client-supplied path records an `io open` event carrying that path, and when the path has a `..` segment or is absolute, a `path traversal` event fires with it. The second one has the `path traversal` [impact tag](https://eyalsec.com/docs/events#impact-tags) and is graded Critical when the data came from the [network](https://eyalsec.com/docs/taint-sources#socket). The event carries the call stack at the open, so you can see which endpoint or background task performed it. On uploads the write is what matters. es-python grades a write as a disclosure signal: it records where the data went and whether other users on the machine can read that file. The vulnerable upload shows a write whose destination was chosen by the request; the fixed upload writes to a name generated in the process, so the request no longer steers the path. Know the limits. Matching is byte-level and POSIX-only and does not decode percent-encoding itself; the router decodes the URL, so the check sees the same decoded string your code passes on. The `..` test is on the value, not on the resolved result, so a symlink inside the store that points outside is not flagged; that is what `resolve()` in the fix and `follow_symlink=False` are for. The [Block backend network attacks](https://eyalsec.com/docs/rule-templates#block-backend) template, or any [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) on `path traversal`, raises a `RuntimeError` before the file is opened, which your exception handlers turn into an error response. #### Review checklist {#review} 1. Search for `:path}` in route declarations and for `FileResponse(`. 2. Search for `.filename` used in a path expression. 3. Add `resolve()` plus `is_relative_to()` to every hand-built file endpoint. 4. Mount `StaticFiles` for public assets. 5. Identify stored files by generated ids. #### References {#references} - Reference: [FastAPI documentation: Path Parameters, path convertor](https://fastapi.tiangolo.com/tutorial/path-params/) (checked 2026-10-12) - Reference: [FastAPI documentation: Custom Response, FileResponse](https://fastapi.tiangolo.com/advanced/custom-response/) (checked 2026-10-12) - Reference: [FastAPI documentation: Request Files](https://fastapi.tiangolo.com/tutorial/request-files/) (checked 2026-10-12) - Reference: [Starlette documentation: Static Files](https://starlette.dev/staticfiles/) (checked 2026-10-12) ### Server-side request forgery (SSRF) in Django URL: https://eyalsec.com/vulnerabilities/ssrf/django | Class: https://eyalsec.com/vulnerabilities/ssrf | Framework: Django In Django the URL that causes an SSRF has usually passed form validation, because Django's URL validation answers "is this a URL" and was never meant to answer "is this a safe place for my server to connect to". #### How it arises in Django {#how} **`URLField` feels like a control.** A webhook, avatar or feed URL goes through `forms.URLField` or `models.URLField`, which use `URLValidator`. Django documents what that validator accepts: a value that looks like a URL, with the default schemes `http`, `https`, `ftp` and `ftps`, and it states that loopback addresses and reserved IP spaces are considered valid. So `http://127.0.0.1:6379/` and `http://169.254.169.254/latest/meta-data/` are valid form input. That is the documented, correct behaviour of a syntax check. **Stored URLs, fetched later.** The URL is saved on a model: `Integration.webhook_url`, `Feed.source`. A Celery beat task fetches it hours later. The request that planted it is long gone, the fetch runs with the worker's network position, and whoever can edit that row controls an internal HTTP client. **Redirects.** Even a URL that points at a public host can answer with a `302` to an internal one. Requests performs location redirection by default for all verbs except HEAD, so a check made on the submitted URL says nothing about where the connection finally goes. **Server-side fetch features.** Link previews, "import from URL", PDF rendering of a user-supplied page, image fetching for thumbnails, and health-check dashboards that let staff enter a host. #### Vulnerable and fixed {#code} ```python import requests from django import forms from django.http import JsonResponse class ImportForm(forms.Form): url = forms.URLField() def import_feed(request): form = ImportForm(request.POST) if not form.is_valid(): return JsonResponse({"errors": form.errors}, status=400) body = requests.get(form.cleaned_data["url"], timeout=5).text return JsonResponse({"bytes": len(body)}) ``` Add the check Django leaves to you: resolve the host, refuse anything that is not a public address, restrict the scheme, and do not follow redirects automatically. ```python import ipaddress import socket from urllib.parse import urlsplit import requests from django import forms from django.core.exceptions import ValidationError from django.http import JsonResponse def public_http_url(value): parts = urlsplit(value) if parts.scheme not in ("http", "https") or not parts.hostname: raise ValidationError("http or https URLs only") for info in socket.getaddrinfo(parts.hostname, parts.port or 443): if not ipaddress.ip_address(info[4][0]).is_global: raise ValidationError("this host is not allowed") class ImportForm(forms.Form): url = forms.URLField(validators=[public_http_url]) def import_feed(request): form = ImportForm(request.POST) if not form.is_valid(): return JsonResponse({"errors": form.errors}, status=400) resp = requests.get(form.cleaned_data["url"], timeout=5, allow_redirects=False) return JsonResponse({"status": resp.status_code, "bytes": len(resp.content)}) ``` A validator cannot close the gap between the lookup it makes and the lookup the HTTP client makes a moment later. For that, send outbound fetches through an egress proxy or a network policy that cannot reach internal ranges. #### What Django already does for you {#built-in} `URLValidator` rejects malformed values and unexpected schemes, and `schemes` can be narrowed to `["https"]`. Custom validators plug into forms and model fields in one line, as above, so the destination rule lives beside the field. `ALLOWED_HOSTS` protects the inbound side. Outbound destination policy is outside the framework's scope, and its documentation does not claim otherwise. #### What es-python adds {#es-python} es-python reports at the point where the connection is actually made, which is the one place the submitted URL, the DNS answer and any redirect all converge. The base events are `socket getaddrinfo` and `socket connect`, the calls that `requests` and `urllib` funnel through. When the destination steered by untrusted data is a loopback, link-local or private-range address, an `ssrf` event fires beside them with the `ssrf` [impact tag](https://eyalsec.com/docs/events#impact-tags). Because the decision is taken on the address being dialled, it does not depend on how the URL was written. A public-looking hostname that resolves internally through an explicit Python lookup, and a redirect that the HTTP client followed inside the application, both end in a connect to an internal address and both fire. For the stored-URL case, turn on the [db_rows source](https://eyalsec.com/docs/taint-sources#db-rows) with [socket](https://eyalsec.com/docs/taint-sources#socket). The webhook URL read back from the model is then followed into the worker's HTTP call. The limits are worth stating. The address test recognises standard dotted-quad and canonical IPv6 forms, not alternate encodings of the same address, and there is no DNS-rebinding coverage. With the [Block backend network attacks](https://eyalsec.com/docs/rule-templates#block-backend) template, or any [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) on `ssrf`, the connection raises a `RuntimeError` before it is made. #### Review checklist {#review} 1. List every `URLField` whose value the server later fetches. 2. Attach a destination validator and narrow `schemes`. 3. Set `allow_redirects=False`, or re-validate every hop. 4. Re-validate stored URLs at fetch time, not only when they are saved. 5. Route outbound fetches through an egress-restricted network path. #### References {#references} - Reference: [Django documentation: Validators, URLValidator](https://docs.djangoproject.com/en/5.2/ref/validators/) (checked 2026-10-12) - Reference: [Django documentation: Settings, ALLOWED_HOSTS](https://docs.djangoproject.com/en/5.2/ref/settings/) (checked 2026-10-12) - Reference: [Requests documentation: Quickstart, Redirection and History](https://requests.readthedocs.io/en/latest/user/quickstart/) (checked 2026-10-12) ### Server-side request forgery (SSRF) in Flask URL: https://eyalsec.com/vulnerabilities/ssrf/flask | Class: https://eyalsec.com/vulnerabilities/ssrf | Framework: Flask Flask is where small proxy endpoints get written (an image fetcher, a link preview, a CORS workaround), and each of them is an HTTP client that a visitor aims by filling in a `url` parameter. #### How it arises in Flask {#how} **The pass-through proxy.** A front-end cannot call a third-party API because of CORS, so someone adds `/proxy?url=...` to the Flask backend and returns whatever comes back. It reflects the response body, which makes it the most useful kind of SSRF to an attacker: internal pages and cloud metadata are returned verbatim. **Link previews and unfurlers.** A chat or CMS feature fetches a pasted URL to extract a title and an image. The response is not reflected, but internal services that act on a GET are still reachable, and timing or error text often leaks whether a port is open. **Image and PDF pipelines.** `?src=` for a thumbnailer; an HTML-to-PDF endpoint that loads remote stylesheets and images named in the submitted HTML. **Substring allowlists.** The first defence added is usually `if "example.com" in url` or `url.startswith("https://api.example.com")`. Both pass `https://api.example.com.attacker.net/` and `https://api.example.com@10.0.0.5/`. **Redirects.** Requests performs location redirection by default for all verbs except HEAD. A URL that passes any check on its text can respond with a redirect to `http://localhost:5000/admin` and the library will follow it. Flask has no built-in HTTP client and therefore no setting that governs these calls. The route is a few lines, there is no form class to hang a validator on, and the check has to be written by hand. #### Vulnerable and fixed {#code} ```python import requests from flask import Flask, Response, request app = Flask(__name__) @app.get("/proxy") def proxy(): upstream = requests.get(request.args["url"], timeout=5) return Response(upstream.content, content_type=upstream.headers.get("Content-Type")) ``` If the set of upstreams is known, do not accept a URL at all: accept a key and a path. This removes the destination from the request: ```python import requests from flask import Flask, Response, abort, request app = Flask(__name__) UPSTREAMS = {"weather": "https://api.weather.example", "rates": "https://rates.example"} @app.get("/proxy//") def proxy(service, resource): base = UPSTREAMS.get(service) if base is None or ".." in resource.split("/"): abort(404) upstream = requests.get( f"{base}/{resource}", params=request.args, timeout=5, allow_redirects=False ) return Response(upstream.content, content_type=upstream.headers.get("Content-Type")) ``` When arbitrary URLs are the feature, as in a link preview, parse with `urllib.parse.urlsplit`, resolve the hostname, reject any address for which `ipaddress.ip_address(...).is_global` is false, disable redirects, and place the fetcher on a network segment that cannot reach internal services. #### What Flask and the libraries already do for you {#built-in} Flask's routing lets you express the safe design directly: a `string` converter for the service key and a dictionary lookup. Werkzeug parses query strings so you never split URLs by hand. Requests exposes `allow_redirects` per call and records every hop in `Response.history`, which makes redirect handling auditable. Python's `ipaddress` module classifies loopback, link-local and private ranges for you. The piece that is yours is deciding which destinations are acceptable. #### What es-python adds {#es-python} Text checks on a URL are fragile because the URL is not what the network sees. es-python makes its decision later, on the connection itself. Every outbound call made with request-controlled data records the base events `socket getaddrinfo` and `socket connect`, so the events list shows each host your proxy was asked to reach. When the address is loopback, link-local or in a private range, an `ssrf` event is added, tagged `ssrf` in the [events list](https://eyalsec.com/docs/events#impact-tags). This is what covers the two bypasses above. `https://api.example.com@10.0.0.5/` passes a `startswith` test and still dials `10.0.0.5`, and the event reports that address. A redirect followed inside `requests` leads to a second connect, to the internal target, which is reported like the first. Read the absence of an event carefully. The address test recognises the standard dotted-quad and canonical IPv6 spellings, not every alternate encoding. A name is caught as internal when the program resolves it through Python. DNS rebinding is not covered. For those cases the base `socket connect` event on untrusted input is the backstop: it shows every destination a request steered, internal or not. With the fixed proxy, the host comes from a dictionary in source, so the request no longer steers the destination. The [Block backend network attacks](https://eyalsec.com/docs/rule-templates#block-backend) template, or any [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) on `ssrf` for the [socket source](https://eyalsec.com/docs/taint-sources#socket), raises a `RuntimeError` before the connection is made. #### Review checklist {#review} 1. Search for `requests.`, `urllib.request.urlopen(` and `httpx.` calls whose URL derives from `request`. 2. Replace URL parameters with a service key wherever the upstreams are known. 3. Remove substring and prefix checks on URLs. 4. Set `allow_redirects=False` on user-directed fetches. 5. Require IMDSv2 or the equivalent on cloud hosts, and restrict egress. #### References {#references} - Reference: [Requests documentation: Quickstart, Redirection and History](https://requests.readthedocs.io/en/latest/user/quickstart/) (checked 2026-10-12) - Reference: [Flask documentation: API, URL route registrations](https://flask.palletsprojects.com/en/stable/api/) (checked 2026-10-12) - Reference: [Flask documentation: Security Considerations](https://flask.palletsprojects.com/en/stable/web-security/) (checked 2026-10-12) ### Server-side request forgery (SSRF) in FastAPI URL: https://eyalsec.com/vulnerabilities/ssrf/fastapi | Class: https://eyalsec.com/vulnerabilities/ssrf | Framework: FastAPI A FastAPI model field typed `HttpUrl` guarantees a well-formed http or https URL, and that guarantee is easy to mistake for a statement about where the URL points. #### How it arises in FastAPI {#how} **`HttpUrl` is a syntax type.** Pydantic documents `HttpUrl` as a type that accepts any http or https URL, with a maximum length of 2083, where a top-level domain is not required and a host is not required. `http://localhost:8000/` and `http://10.0.0.5/` are valid values. The type removes `file://` and `gopher://`, which is useful, and leaves the destination question open. **Webhook registration.** `POST /webhooks` with `{"callback_url": "..."}` is close to a standard feature of API products. The service later sends events to that URL from inside its own network. Often a "send test event" endpoint fires immediately and returns the status code, which gives the caller a port scanner. **Service meshes.** FastAPI services call each other over plain HTTP with no authentication because the network is trusted. That is precisely what makes an SSRF valuable: the forged request arrives from a trusted peer. **Async clients everywhere.** `httpx.AsyncClient` is the usual choice. HTTPX does not follow redirects by default, which is a safer default than some other clients. Many codebases create one shared client with `follow_redirects=True` so that normal integrations work, and the webhook sender inherits it. **URL-typed query parameters** for import and preview endpoints: `source: HttpUrl`. #### Vulnerable and fixed {#code} ```python import httpx from fastapi import FastAPI from pydantic import BaseModel, HttpUrl app = FastAPI() client = httpx.AsyncClient(follow_redirects=True, timeout=5) class Hook(BaseModel): callback_url: HttpUrl @app.post("/webhooks/test") async def test_hook(hook: Hook): resp = await client.post(str(hook.callback_url), json={"event": "ping"}) return {"status": resp.status_code} ``` Put the destination rule in a dependency, so every endpoint that accepts a callback shares it. It resolves the host without blocking the event loop, rejects non-public addresses, and the sender uses a client that does not follow redirects: ```python import asyncio import ipaddress import httpx from fastapi import Depends, FastAPI, HTTPException from pydantic import BaseModel, HttpUrl app = FastAPI() hook_client = httpx.AsyncClient(follow_redirects=False, timeout=5) class Hook(BaseModel): callback_url: HttpUrl async def public_hook(hook: Hook) -> Hook: url = hook.callback_url loop = asyncio.get_running_loop() infos = await loop.getaddrinfo(url.host, url.port or 443) if not infos or any(not ipaddress.ip_address(i[4][0]).is_global for i in infos): raise HTTPException(status_code=422, detail="callback host is not allowed") return hook @app.post("/webhooks/test") async def test_hook(hook: Hook = Depends(public_hook)): resp = await hook_client.post(str(hook.callback_url), json={"event": "ping"}) return {"delivered": resp.status_code < 400} ``` The response no longer echoes the upstream status. Between the check and the send the name is resolved a second time, so pair this with an egress rule or a forward proxy that refuses internal ranges. #### What FastAPI, Pydantic and HTTPX already do for you {#built-in} `HttpUrl` parses the URL once and gives you `host`, `port` and `scheme` as attributes, so you do not write a URL parser. Dependencies give the destination rule a single home and make it visible in every signature that uses it. HTTPX keeps redirects off unless asked, and applies timeouts by default. Pydantic's `IPvAnyAddress` type covers the case where callers should supply an address directly. #### What es-python adds {#es-python} For a webhook product the practical question is "where did our servers connect because a customer told them to". es-python answers from the running service. The base events `socket getaddrinfo` and `socket connect` fire whenever a destination was steered by untrusted data, and when the address being dialled is loopback, link-local or private, an `ssrf` event with the `ssrf` [impact tag](https://eyalsec.com/docs/events#impact-tags) fires beside them. The decision is taken on the address, after `HttpUrl` validation, after name resolution in Python, and after any redirect the client followed. So the shared `follow_redirects=True` client in the vulnerable version is covered: the redirect produces a second connection, to the internal address, and that is what gets reported. Registered callbacks are stored and used later by a delivery worker. Switch on the [db_rows source](https://eyalsec.com/docs/taint-sources#db-rows) together with [socket](https://eyalsec.com/docs/taint-sources#socket) so the URL read back from the database is followed into the send. Stated limits: alternate encodings of an internal address are not recognised by the address test, a name is identified as internal only when the program resolves it through Python, and DNS rebinding is not covered. The base `socket connect` event remains as the record of every customer-chosen destination. Under the [Block backend network attacks](https://eyalsec.com/docs/rule-templates#block-backend) template, or any [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) on `ssrf`, the connect raises a `RuntimeError` before it is made, which the endpoint returns as an error like any other. #### Review checklist {#review} 1. Find every model field typed `HttpUrl`, `AnyUrl` or `str` that is later fetched. 2. Move destination checks into a shared dependency. 3. Use a dedicated client with `follow_redirects=False` for user-directed calls. 4. Do not return upstream status codes or bodies from test endpoints. 5. Authenticate service-to-service calls so a forged request is not automatically trusted. #### References {#references} - Reference: [Pydantic documentation: Network Types, HttpUrl](https://docs.pydantic.dev/latest/api/networks/) (checked 2026-10-12) - Reference: [HTTPX documentation: QuickStart, Redirection and History](https://www.python-httpx.org/quickstart/) (checked 2026-10-12) - Reference: [FastAPI documentation: Dependencies](https://fastapi.tiangolo.com/tutorial/dependencies/) (checked 2026-10-12) ### NoSQL injection in Flask URL: https://eyalsec.com/vulnerabilities/nosql-injection/flask | Class: https://eyalsec.com/vulnerabilities/nosql-injection | Framework: Flask `request.get_json()` returns whatever JSON the client sent, including nested objects, and a Flask view that drops one of those values into a PyMongo filter has let the client write part of the query. #### How it arises in Flask {#how} MongoDB's own documentation makes the reassuring half of the point: a client program assembles a query as a BSON object, not a string, so traditional SQL injection attacks are not a problem. A value like `' OR 1=1` is just a string that matches nothing. The other half is about types. In PyMongo a filter is a Python dictionary, and a dictionary value can itself be a dictionary of operators. Flask does not fix the type of a JSON field. **Operator injection.** The view expects `{"username": "ana", "password": "secret"}` and builds `{"username": u, "password": p}`. The client sends `{"username": "admin", "password": {"$ne": null}}`. The filter becomes "username is admin and password is not null", and `find_one()` returns the admin document. **Form and query-string variants.** `request.args` and `request.form` hold strings, which is safer by accident. Code that parses a `filter` parameter with `json.loads()` to support "advanced search" reintroduces dictionaries. **Whole-filter passthrough.** `collection.find(request.get_json())` for an internal dashboard. The caller chooses every field and operator, including ones that read other tenants' documents. **`$where` and server-side JavaScript.** MongoDB lists `$where`, `mapReduce`, `$accumulator` and `$function` as operations that run JavaScript expressions on the server and says you must exercise care to prevent users from submitting malicious JavaScript. A `$where` string built with an f-string is code injection into the database. #### Vulnerable and fixed {#code} ```python from flask import Flask, jsonify, request from pymongo import MongoClient app = Flask(__name__) db = MongoClient()["shop"] @app.post("/login") def login(): body = request.get_json() user = db.users.find_one({"username": body["username"], "password": body["password"]}) return jsonify(ok=user is not None) @app.get("/products") def products(): limit = request.args.get("max_price", "100") rows = db.products.find({"$where": f"this.price < {limit}"}) return jsonify([r["name"] for r in rows]) ``` Require the types you expect before building the filter, and express the comparison with an operator and a number, not JavaScript. (Passwords belong in a hash compared in Python; that change is separate from the injection fix and shown here.) ```python from flask import Flask, abort, jsonify, request from pymongo import MongoClient from werkzeug.security import check_password_hash app = Flask(__name__) db = MongoClient()["shop"] @app.post("/login") def login(): body = request.get_json(silent=True) or {} username, password = body.get("username"), body.get("password") if not isinstance(username, str) or not isinstance(password, str): abort(400) user = db.users.find_one({"username": username}) ok = user is not None and check_password_hash(user["password_hash"], password) return jsonify(ok=ok) @app.get("/products") def products(): limit = request.args.get("max_price", default=100.0, type=float) rows = db.products.find({"price": {"$lt": limit}}) return jsonify([r["name"] for r in rows]) ``` #### What Flask, PyMongo and MongoDB already do for you {#built-in} PyMongo sends filters as BSON, so there is no query string to break out of. Werkzeug's `request.args.get(..., type=float)` converts and falls back to the default on bad input, which is the shortest type check available. MongoDB can disable server-side JavaScript entirely with `security.javascriptEnabled: false` or `--noscripting`, and its documentation notes that most operations can be expressed without JavaScript. #### What es-python adds {#es-python} es-python reports when request data reaches a PyMongo `Collection` method that takes a filter or a pipeline: `find`, `find_one`, `count_documents`, the `delete_*`, `update_*` and `find_one_and_*` methods, and `aggregate`. The label is `nosql mongodb`, with the `nosql injection` [impact tag](https://eyalsec.com/docs/events#impact-tags). The report is narrowed to what makes this class dangerous. A request string used as a plain value, as in the fixed login, is ordinary querying. What es-python singles out are `$`-prefixed operator keys in the filter that came from the request, and the value of the code-bearing operators such as `$where`, `$expr` and `$function`. So the `{"$ne": null}` login and the formatted `$where` string are both reported, and the event shows the operator and the view. That gives you a direct test. Replay a login with an operator object as the password under es-python with the [socket source](https://eyalsec.com/docs/taint-sources#socket) on. If the event appears, the type check is missing somewhere on that path. An event built from network data is graded Critical. The ready-made [Block backend network attacks](https://eyalsec.com/docs/rule-templates#block-backend) template includes `nosql mongodb`: with it applied, a MongoDB query operator that came from the request makes the call raise a `RuntimeError` before the query runs, and correct queries are not blocked. #### Review checklist {#review} 1. Search for `find(`, `find_one(`, `update_one(` and `delete_many(` whose filter contains a value from `request`. 2. Check `isinstance(value, str)` (or convert) before a value enters a filter. 3. Never pass a client-built dictionary as the filter. 4. Replace `$where` with query operators; disable server-side JavaScript if you can. 5. Validate bodies with a schema library so types are enforced in one place. #### References {#references} - Reference: [MongoDB documentation: FAQ, How does MongoDB address SQL or Query injection?](https://www.mongodb.com/docs/manual/faq/fundamentals/) (checked 2026-10-12) - Reference: [MongoDB documentation: $where](https://www.mongodb.com/docs/manual/reference/operator/query/where/) (checked 2026-10-12) - Reference: [Werkzeug documentation: Data Structures, get() with type](https://werkzeug.palletsprojects.com/en/stable/datastructures/) (checked 2026-10-12) - Reference: [PyMongo documentation: Collection](https://pymongo.readthedocs.io/en/stable/api/pymongo/collection.html) (checked 2026-10-12) ### NoSQL injection in FastAPI URL: https://eyalsec.com/vulnerabilities/nosql-injection/fastapi | Class: https://eyalsec.com/vulnerabilities/nosql-injection | Framework: FastAPI FastAPI is the one framework here where the default way of writing an endpoint blocks MongoDB operator injection, because a field declared `str` cannot arrive as an object, so the bug appears exactly where a model says `dict` or `Any`. #### How it arises in FastAPI {#how} Operator injection needs a JSON object where the code expected a scalar: `{"password": {"$ne": null}}`. With `password: str` on a Pydantic model, FastAPI answers that request with a 422 and the handler never runs. This is real protection and it costs nothing. It disappears in four common situations. **Arbitrary dict bodies.** FastAPI documents "bodies of arbitrary dicts": declare `weights: dict[int, float]` or simply `filters: dict` and any keys are accepted. Search endpoints use this to let clients send `{"status": "open", "owner": "ana"}`, then pass the dictionary to `find()`. The client can equally send `{"owner": {"$ne": ""}}` or `{"$where": "..."}`. **`Any` and untyped parameters.** A field typed `Any`, or a handler that takes `payload: dict = Body(...)` to stay flexible during prototyping. **Raw request access.** `await request.json()` skips the model layer completely and returns Python objects of whatever shape was sent. **Sort and projection parameters.** `sort: str` split into field and direction and passed to `sort()`, or a `fields` list used as a projection. These choose field names. They are not operator injection, but they can expose fields such as password hashes that the response model would otherwise omit when the endpoint returns raw documents. A note on drivers: Motor's documentation states it will be deprecated on May 14th, 2026 in favour of the PyMongo Async driver, so new FastAPI code is moving to PyMongo's own API. #### Vulnerable and fixed {#code} ```python from fastapi import FastAPI from pymongo import MongoClient app = FastAPI() db = MongoClient()["tickets"] @app.post("/tickets/search") def search(filters: dict, tenant: str): query = {"tenant": tenant, **filters} return [{"id": str(t["_id"]), "title": t["title"]} for t in db.tickets.find(query)] ``` Besides operator injection, a `tenant` key inside `filters` overrides the real one. The fix names each searchable field with a scalar type and forbids extras, so an object or an unknown key is a 422: ```python from enum import Enum from typing import Optional from fastapi import FastAPI from pydantic import BaseModel, ConfigDict from pymongo import MongoClient app = FastAPI() db = MongoClient()["tickets"] class Status(str, Enum): open = "open" closed = "closed" class TicketFilter(BaseModel): model_config = ConfigDict(extra="forbid") status: Optional[Status] = None owner: Optional[str] = None @app.post("/tickets/search") def search(filters: TicketFilter, tenant: str): query = {"tenant": tenant} if filters.status is not None: query["status"] = filters.status.value if filters.owner is not None: query["owner"] = filters.owner return [{"id": str(t["_id"]), "title": t["title"]} for t in db.tickets.find(query)] ``` In a real service `tenant` comes from the authenticated principal through a dependency, not from a parameter. #### What FastAPI and Pydantic already do for you {#built-in} Scalar field types are the defence, and they are the path of least resistance in FastAPI. `Enum` fields restrict values to a list. Model configuration can forbid unknown keys. Response models strip fields you did not declare, which limits what a projection mistake can leak. On the database side, MongoDB documents that queries are BSON objects, so string-style injection does not apply, and that server-side JavaScript (`$where`, `$function`, `$accumulator`, `mapReduce`) can be switched off. #### What es-python adds {#es-python} Type hints protect the fields you typed. es-python reports on the ones that got through. When request data reaches a PyMongo `Collection` method that takes a filter or pipeline (`find`, `find_one`, `count_documents`, the `update_*`, `delete_*` and `find_one_and_*` methods, `aggregate`), it records `nosql mongodb` with the `nosql injection` [impact tag](https://eyalsec.com/docs/events#impact-tags). The event concentrates on the dangerous parts: `$`-prefixed operator keys in the filter that arrived from the request, and the values of code-bearing operators such as `$where`, `$expr` and `$function`. A typed string used as a value is ordinary querying. That makes the event a precise signal for this framework: it points at an endpoint where a `dict`, an `Any` or a raw `request.json()` let an operator through, which is the list a reviewer wants. Coverage here is for PyMongo's `Collection` methods as described on the [class page](https://eyalsec.com/vulnerabilities/nosql-injection). If your service uses an async driver, confirm it on your own stack: send one test request with an operator object under es-python with the [socket source](https://eyalsec.com/docs/taint-sources#socket) on and check that the event appears before you rely on it. The [Block backend network attacks](https://eyalsec.com/docs/rule-templates#block-backend) template can block `nosql mongodb`: the query call raises a `RuntimeError` before it runs when an operator came from the request. #### Review checklist {#review} 1. Search models and handlers for `dict`, `Any`, `Body(...)` without a model, and `request.json()`. 2. Replace free-form filters with a model of typed, optional fields and `extra="forbid"`. 3. Take the tenant from authentication, and add it to the query last. 4. Map sort and projection choices through an `Enum`. 5. Disable server-side JavaScript in MongoDB if no feature needs it. #### References {#references} - Reference: [FastAPI documentation: Body, Nested Models, bodies of arbitrary dicts](https://fastapi.tiangolo.com/tutorial/body-nested-models/) (checked 2026-10-12) - Reference: [MongoDB documentation: FAQ, How does MongoDB address SQL or Query injection?](https://www.mongodb.com/docs/manual/faq/fundamentals/) (checked 2026-10-12) - Reference: [Motor documentation: Tutorial, deprecation notice](https://motor.readthedocs.io/en/stable/tutorial-asyncio.html) (checked 2026-10-12) - Reference: [Pydantic documentation: Configuration](https://docs.pydantic.dev/latest/api/config/) (checked 2026-10-12) ### Regular expression denial of service (ReDoS) in Django URL: https://eyalsec.com/vulnerabilities/redos/django | Class: https://eyalsec.com/vulnerabilities/redos | Framework: Django Django runs developer-written regular expressions against attacker-chosen strings on every request, in URL routing and in validation, so one pattern with nested repetition can let a single request hold a worker for seconds. #### How it arises in Django {#how} **`re_path()` routes.** The route argument of `re_path()` is a regular expression compatible with Python's `re` module, matched against the request path. The path is the most attacker-controlled string there is: no login, no CSRF token, no form. A route such as `^docs/(?P([\w-]+/?)+)$` looks reasonable and backtracks exponentially on a long run of word characters followed by a character that cannot match. **`RegexValidator`.** `RegexValidator(regex=...)` accepts a pattern string or a pre-compiled expression and runs it on the submitted value. Validators for SKUs, phone numbers and usernames are written once and never timed. `URLValidator` and `EmailValidator` are regex-based too; Django's security team has shipped fixes for potential regular expression denial of service in them and in text utilities such as `Truncator.words()`, which is a good reason to stay on a patched release. **User-supplied patterns.** Admin search boxes and "advanced filter" features that pass a user's text to `re.search()` or `re.compile()`. Here the attacker writes the pattern, so no review of your own patterns helps. **Length is the multiplier.** Django limits request body size through `DATA_UPLOAD_MAX_MEMORY_SIZE`, but a path or a field of a few thousand characters is more than enough for a quadratic or exponential pattern. **Synchronous workers.** A typical deployment has a small fixed number of worker processes. Four slow requests against four workers is an outage. #### Vulnerable and fixed {#code} ```python import re from django.core.validators import RegexValidator from django.http import JsonResponse from django.urls import re_path sku_validator = RegexValidator(r"^([A-Z0-9]+-?)+$") def search(request): pattern = re.compile(request.GET["q"]) names = ["alpha", "beta", "gamma"] return JsonResponse({"hits": [n for n in names if pattern.search(n)]}) urlpatterns = [ re_path(r"^docs/(?P([\w-]+/?)+)$", search), ] ``` Remove the nested quantifier, bound the lengths, and treat the user's search text as a literal with `re.escape()`. Where a route does not need a regular expression, a `path()` converter avoids the engine's backtracking altogether: ```python import re from django.core.validators import RegexValidator from django.http import JsonResponse from django.urls import path sku_validator = RegexValidator(r"^[A-Z0-9]{1,12}(-[A-Z0-9]{1,12}){0,5}$") def search(request, slug=""): needle = request.GET.get("q", "")[:64] pattern = re.compile(re.escape(needle)) names = ["alpha", "beta", "gamma"] return JsonResponse({"hits": [n for n in names if pattern.search(n)]}) urlpatterns = [ path("docs/", search), ] ``` #### What Django already does for you {#built-in} `path()` with converters is the default routing API and covers most routes without hand-written expressions. Form and model fields have `max_length`, which caps the input before a validator sees it. Upload and field-count limits bound the request as a whole. And the project treats ReDoS in its own code as a security issue with advisories and patch releases. #### What es-python adds {#es-python} Static checks reason about the shape of a pattern. es-python measures what the engine did. A `redos` event fires only when a match actually backtracked catastrophically over untrusted input: es-python counts the engine's steps and reports when the count passes both an absolute threshold and a steps-per-character threshold. The second threshold is what separates genuine super-linear backtracking from an ordinary scan of a long string. So a `redos` event names a real pattern, a real input and the place in your project that ran them together. This suits Django particularly well, because Django performs a great many harmless regex matches on request data. The [Low noise](https://eyalsec.com/docs/rule-templates#low-noise) template drops the routine `re match` and `re search` events from routing and form validation, and keeps `redos` visible. You get the one route or validator that blew up, not thousands of rows. Two reading notes. The `redos` [impact tag](https://eyalsec.com/docs/events#impact-tags) always travels with a `regex bypass` tag, because a regex event can also mean a filter pattern that an attacker relaxed; if the call site validates input, consider that reading first. For the user-supplied pattern case, `re compile` fires when request text becomes the pattern itself. And one limit: a pattern whose blow-up lives entirely inside a single bounded repeat is not counted, so `redos` covers the common nested-quantifier shape, not every possible one. Under a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise), the match is aborted with a `RuntimeError`, which frees the worker. #### Review checklist {#review} 1. Search for `re_path(` and replace with `path()` where a converter is enough. 2. Look for a quantifier applied to a group that itself contains a quantifier. 3. Add `max_length` to every field with a `RegexValidator`. 4. Never compile request text as a pattern; use `re.escape()`. 5. Load-test validators with long near-matching inputs. #### References {#references} - Reference: [Django documentation: django.urls functions, re_path()](https://docs.djangoproject.com/en/5.2/ref/urls/) (checked 2026-10-12) - Reference: [Django documentation: Validators, RegexValidator](https://docs.djangoproject.com/en/5.2/ref/validators/) (checked 2026-10-12) - Reference: [Django documentation: Archive of security issues](https://docs.djangoproject.com/en/5.2/releases/security/) (checked 2026-10-12) - Reference: [Python documentation: re](https://docs.python.org/3/library/re.html) (checked 2026-10-12) ### Regular expression denial of service (ReDoS) in FastAPI URL: https://eyalsec.com/vulnerabilities/redos/fastapi | Class: https://eyalsec.com/vulnerabilities/redos | Framework: FastAPI Patterns declared on FastAPI parameters are checked by an engine that does not backtrack, so ReDoS in a FastAPI service comes from the places that fall back to Python's `re` module, some of them without the author noticing. #### How it arises in FastAPI {#how} `Query(pattern="^fixedquery$")` and `Field(pattern=...)` are FastAPI's documented way to constrain a string. Validation is done by Pydantic, whose `regex_engine` setting defaults to `rust-regex`. Pydantic describes that engine as non-backtracking and therefore more DDoS resistant, with the trade-off that it does not support all regex features. For declared patterns, that default removes the class. Backtracking returns through four doors. **`regex_engine="python-re"`.** Lookarounds and backreferences are not available in the default engine. A team that needs one sets the model's config to `python-re`, which Pydantic describes as supporting all regex features. Every pattern on that model now runs on the backtracking engine. **Compiled patterns.** Pydantic notes that if you use a compiled regex pattern, the `python-re` engine is used regardless of the setting, so that flags such as `re.IGNORECASE` are respected. Writing `pattern=re.compile(...)` to get case-insensitivity silently changes engines. **Custom validators.** `@field_validator` functions call `re.match()` or `re.fullmatch()` directly. These are ordinary Python and always backtrack. **Handler code.** Tokenisers, log parsers, Markdown or mention extraction on request text, and search endpoints that accept a user's pattern. **One event loop.** In an `async def` endpoint a catastrophic match does not just occupy a worker. It blocks the event loop, so every other in-flight request on that process stalls until the match finishes. #### Vulnerable and fixed {#code} ```python import re from fastapi import FastAPI from pydantic import BaseModel, ConfigDict, Field, field_validator app = FastAPI() TAGS = re.compile(r"^(\w+\s?)+$") class Post(BaseModel): model_config = ConfigDict(regex_engine="python-re") slug: str = Field(pattern=r"^([a-z0-9]+-?)+$") tags: str @field_validator("tags") @classmethod def check_tags(cls, value: str) -> str: if not TAGS.match(value): raise ValueError("bad tags") return value @app.post("/posts") async def create(post: Post): return {"slug": post.slug} ``` Keep the default engine, rewrite both patterns without a quantified group that contains a quantifier, and bound the lengths so the input size is fixed before any match: ```python import re from fastapi import FastAPI from pydantic import BaseModel, Field, field_validator app = FastAPI() TAG = re.compile(r"\w{1,32}") class Post(BaseModel): slug: str = Field(max_length=80, pattern=r"^[a-z0-9]+(-[a-z0-9]+)*$") tags: str = Field(max_length=200) @field_validator("tags") @classmethod def check_tags(cls, value: str) -> str: if not all(TAG.fullmatch(part) for part in value.split()): raise ValueError("bad tags") return value @app.post("/posts") async def create(post: Post): return {"slug": post.slug} ``` #### What FastAPI and Pydantic already do for you {#built-in} The default regex engine is the main protection, and it applies to every `pattern` you declare on a parameter or field without any configuration. `max_length` and `min_length` sit beside `pattern` in the same call, so bounding input is one more keyword. Declarative constraints also appear in the OpenAPI schema, which keeps hand-written validators to a minimum. Pydantic documents the engine choice and the compiled-pattern exception clearly; the work is knowing which of your models have opted out. #### What es-python adds {#es-python} The engine switch is invisible at the call site: `Field(pattern=...)` reads the same whichever engine runs it. es-python reports from behaviour. When a match in Python's `re` engine backtracks catastrophically over data from the request, it records a `redos` event. The decision uses the engine's own step count, which must pass an absolute threshold and a steps-per-character threshold; the second one keeps a plain linear scan of a large body from being reported. Patterns checked by the non-backtracking default engine do not go through Python's `re` at all, so they produce no regex events. In practice this sorts a codebase for you. Run the test suite or a fuzzing pass under es-python with the [socket source](https://eyalsec.com/docs/taint-sources#socket) on. A `redos` event on a model means that model fell back to `python-re` and has a dangerous pattern. A `redos` event in a validator or handler points at the `re` call. No events on the declared patterns is the expected result. The [Low noise](https://eyalsec.com/docs/rule-templates#low-noise) template drops ordinary `re match` and `re search` events, which routing produces on every request, and leaves `redos` visible. The `redos` [impact tag](https://eyalsec.com/docs/events#impact-tags) is always paired with `regex bypass`, since a regex on untrusted input may also be a filter being evaded. One limit to keep in mind: a blow-up contained entirely inside a single bounded repeat is not counted. With a [Raise rule](https://eyalsec.com/docs/report-and-raise#raise) on `redos`, the match is aborted with a `RuntimeError`. In an async service that matters more than usual, because it releases the event loop for everyone else. #### Review checklist {#review} 1. Search for `regex_engine` and for `pattern=re.compile(`. 2. Search validators and handlers for direct `re.` calls on request data. 3. Add `max_length` wherever there is a `pattern`. 4. Rewrite `(x+y?)+` shapes as `x+(yx+)*`. 5. Move unavoidable heavy matching off the event loop and give it a timeout. #### References {#references} - Reference: [Pydantic documentation: Configuration, regex_engine](https://docs.pydantic.dev/latest/api/config/) (checked 2026-10-12) - Reference: [FastAPI documentation: Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/) (checked 2026-10-12) - Reference: [Python documentation: re](https://docs.python.org/3/library/re.html) (checked 2026-10-12) ### Django vulnerability classes URL: https://eyalsec.com/vulnerabilities/for/django | Framework: Django The vulnerability classes es-python reports in running Django applications: how each arises, a vulnerable snippet, its fix and the event. - [SQL injection in Django](https://eyalsec.com/vulnerabilities/sql-injection/django) - [Command injection in Django](https://eyalsec.com/vulnerabilities/command-injection/django) - [Code injection in Django](https://eyalsec.com/vulnerabilities/code-injection/django) - [Server-side template injection (SSTI) in Django](https://eyalsec.com/vulnerabilities/server-side-template-injection/django) - [Insecure deserialization in Django](https://eyalsec.com/vulnerabilities/insecure-deserialization/django) - [Path traversal in Django](https://eyalsec.com/vulnerabilities/path-traversal/django) - [Server-side request forgery (SSRF) in Django](https://eyalsec.com/vulnerabilities/ssrf/django) - [Regular expression denial of service (ReDoS) in Django](https://eyalsec.com/vulnerabilities/redos/django) ### Flask vulnerability classes URL: https://eyalsec.com/vulnerabilities/for/flask | Framework: Flask The vulnerability classes es-python reports in running Flask applications: how each arises, a vulnerable snippet, its fix and the event. - [SQL injection in Flask](https://eyalsec.com/vulnerabilities/sql-injection/flask) - [Command injection in Flask](https://eyalsec.com/vulnerabilities/command-injection/flask) - [Code injection in Flask](https://eyalsec.com/vulnerabilities/code-injection/flask) - [Server-side template injection (SSTI) in Flask](https://eyalsec.com/vulnerabilities/server-side-template-injection/flask) - [Insecure deserialization in Flask](https://eyalsec.com/vulnerabilities/insecure-deserialization/flask) - [Path traversal in Flask](https://eyalsec.com/vulnerabilities/path-traversal/flask) - [Server-side request forgery (SSRF) in Flask](https://eyalsec.com/vulnerabilities/ssrf/flask) - [NoSQL injection in Flask](https://eyalsec.com/vulnerabilities/nosql-injection/flask) ### FastAPI vulnerability classes URL: https://eyalsec.com/vulnerabilities/for/fastapi | Framework: FastAPI The vulnerability classes es-python reports in running FastAPI applications: how each arises, a vulnerable snippet, its fix and the event. - [SQL injection in FastAPI](https://eyalsec.com/vulnerabilities/sql-injection/fastapi) - [Command injection in FastAPI](https://eyalsec.com/vulnerabilities/command-injection/fastapi) - [Code injection in FastAPI](https://eyalsec.com/vulnerabilities/code-injection/fastapi) - [Server-side template injection (SSTI) in FastAPI](https://eyalsec.com/vulnerabilities/server-side-template-injection/fastapi) - [Insecure deserialization in FastAPI](https://eyalsec.com/vulnerabilities/insecure-deserialization/fastapi) - [Path traversal in FastAPI](https://eyalsec.com/vulnerabilities/path-traversal/fastapi) - [Server-side request forgery (SSRF) in FastAPI](https://eyalsec.com/vulnerabilities/ssrf/fastapi) - [NoSQL injection in FastAPI](https://eyalsec.com/vulnerabilities/nosql-injection/fastapi) - [Regular expression denial of service (ReDoS) in FastAPI](https://eyalsec.com/vulnerabilities/redos/fastapi)