DEV Community

Cover image for An SSRF in Your Lambda Steals the Execution Role. Action: '*' Hands Over the Account. 14 ESLint Rules Break the Chain.
Ofri Peretz
Ofri Peretz

Posted on • Edited on • Originally published at ofriperetz.dev

An SSRF in Your Lambda Steals the Execution Role. Action: '*' Hands Over the Account. 14 ESLint Rules Break the Chain.

Here is a two-step path from "valid HTTP request" to "attacker owns your AWS
account," and every step compiles:

// Step 1: the handler fetches a URL the caller controls
export const handler = async (event) => {
  const res = await fetch(event.queryStringParameters.callbackUrl); // SSRF
  return { statusCode: 200, body: await res.text() };
};
Enter fullscreen mode Exit fullscreen mode

Step 1 is a server-side request forgery: the caller now drives outbound calls
from inside your trust boundary — VPC-internal services, databases, admin
endpoints not exposed to the internet — and can exfiltrate the response. The
credential prize is the part most people get wrong about Lambda. Lambda has no
EC2 metadata service
— there's no 169.254.169.254 returning role credentials
the way there is on an EC2 box. The execution role's keys are injected as
environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,
AWS_SESSION_TOKEN). So the SSRF that steals them is the one whose client can
read file:///proc/self/environ, or a handler you can coax into reflecting
process.env — not the EC2 IMDS payload people reflexively copy over. Step 2:
if that role's IAM policy contains "Action": "*" (the reflex when "it just
needs to work"), those leaked keys now do anything in your account.

Neither line throws. Neither fails a test. The type-checker is delighted. Both
are source patterns — and that's what eslint-plugin-lambda-security reads.
It's 14 rules for the AWS Lambda / serverless surface — SSRF, IAM
over-permissioning, secrets in env vars, error-detail leakage, unbounded batch
DoS — organized around the OWASP Serverless Top 10,
each pinned to a CWE.

This guide walks the SSRF→IAM takeover chain, the serverless-specific gotchas
(env vars aren't secret — and on Lambda they're where the credentials live), the
full 14-rule map, and exact install/engine support.

Why this passes code review

Neither half of this chain looks like a vulnerability to a reviewer reading the
diff. The fetch is a single line doing an ordinary thing — a webhook callback,
a "fetch the user's avatar URL" feature — and the reviewer isn't picturing the
trust boundary that line sits inside: that the function can reach VPC-internal
services, and that its own role credentials are sitting in process.env one
file:// or reflected-env away. SSRF only registers as dangerous when you
already hold the runtime's internal surface in your head; reading a feature PR,
that intuition isn't there. So the line reads as "calls a URL," and "calls a URL"
is not a red flag.

The "Action": "*" is worse, because it's usually not even in the same PR. The
handler ships in application code; the IAM policy ships in a SAM/CDK/Serverless
template that a different person reviews — often a platform engineer optimizing
for "the deploy stops failing with AccessDenied," not for blast radius. Each
half is locally reasonable. The chain is only visible when you hold both files
at once, which no single reviewer does. That's the gap a linter closes: it reads
the source AST, not the diff, and it doesn't get bored on line 4 of a 600-line
PR.


TL;DR

  • 14 rules, each carrying a CWE id and CVSS, mapped to the OWASP Serverless Top 10.
  • 2 presets: recommended and strict (both enable all 14 — focused plugin, the sane default is everything).
  • Flat-config, CommonJS, ESLint 8 || 9 || 10, Node >= 18. Detects raw handlers, Middy middleware, and IAM policy literals (SAM/CDK/Serverless Framework) — it lints source, no AWS credentials or peer SDK required.

Step 1: SSRF — no-user-controlled-requests

// ❌ no-user-controlled-requests (CWE-918, CVSS 9.1)
const res = await fetch(event.queryStringParameters.callbackUrl);
Enter fullscreen mode Exit fullscreen mode
// ✅ allow-list the destination before you call it
const ALLOWED = new Set(["api.partner.com", "hooks.example.com"]);
const url = new URL(event.queryStringParameters.callbackUrl);
if (!ALLOWED.has(url.hostname)) throw new Error("destination not allowed");
const res = await fetch(url);
Enter fullscreen mode Exit fullscreen mode

The rule flags a request whose URL carries user-controlled input
(rule docs).
The fix is an allow-list of destinations; the rule's own guidance: "Never use
user input directly in URLs."

Here's the nuance that separates a real Lambda threat model from a copied EC2
one — and it's where most "SSRF steals your role" write-ups are quietly wrong.
On EC2, the classic SSRF prize is the IMDS endpoint
(http://169.254.169.254/latest/meta-data/iam/security-credentials/), and on a
box still allowing IMDSv1 a single unauthenticated GET returns the role's
credentials. Lambda is not that. There is no IMDS in a Lambda execution
environment; the role's keys live in AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
/ AWS_SESSION_TOKEN env vars, and the only loopback service is the Lambda
Runtime API
($AWS_LAMBDA_RUNTIME_API), which serves invocation lifecycle, not
credentials. So on Lambda the SSRF that matters reaches internal/VPC services
or reads the credential env directly (a client that follows file:///proc/self/environ,
or a handler coaxed into echoing process.env) — not 169.254.169.254.

Why keep the link-local block anyway? Because the rule reads source, and the
same handler file routinely gets lifted onto EC2 or ECS-on-EC2, where that
exact GET does hit IMDS — and no-user-controlled-requests flags the
user-controlled URL regardless of which compute it lands on, before the deploy
target is even decided. The linter doesn't assume your runtime; it removes the
user-controlled destination that your runtime's internal surface is the only
thing standing in front of.


Step 2: least privilege — no-overly-permissive-iam-policy

What turns a stolen credential from "bad" into "account takeover" is the policy
attached to the role.

// ❌ no-overly-permissive-iam-policy (CWE-732)
policy: { Effect: "Allow", Action: "*", Resource: "*" }
Enter fullscreen mode Exit fullscreen mode
// ✅ scope to exactly what the function needs
policy: {
  Effect: "Allow",
  Action: ["s3:GetObject"],
  Resource: "arn:aws:s3:::my-bucket/*",
}
Enter fullscreen mode Exit fullscreen mode

The rule flags "*" in Action/Resource of IAM policy literals (the shape
you write in SAM, CDK, the Serverless Framework, or inline policy objects —
rule docs).
Its fix message is concrete: restrict to specific resources/actions, e.g.
"arn:aws:s3:::my-bucket/*" instead of "*".
Least privilege is what bounds
the blast radius of step 1 — with a scoped role, the stolen credentials can
read one bucket, not delete your databases.

Make both halves a build error

Two of these patterns just earned an SSRF→takeover chain. Here's the entire
config that fails CI on both — the full install matrix (package
managers, inline tuning, sample output) is below:

npm install --save-dev eslint-plugin-lambda-security
Enter fullscreen mode Exit fullscreen mode
// eslint.config.js — `configs` is a NAMED export
import { configs } from "eslint-plugin-lambda-security";

export default [configs.recommended]; // all 14 rules, CWE-tagged
Enter fullscreen mode Exit fullscreen mode

The serverless gotchas

Lambda has its own footguns that don't exist on a long-lived server:

  • Env vars are not a secret store. no-secrets-in-env (CWE-798) flags secrets assigned to environment variables: they're readable by anyone with lambda:GetFunctionConfiguration (and visible in the console), and one console.log(process.env) dumps them to CloudWatch forever. The fix is a runtime fetch from AWS Secrets Manager / SSM Parameter Store. (Entropy scanners miss the assignment that matters here — why a structural rule beats a secret-scanner is the whole argument of Hardcoded Secrets in AI-Generated Code, and the Autofix That Removes Them.)
  • Logging leaks. no-env-logging (CWE-532) catches process.env going to logs; no-exposed-error-details (CWE-209) catches error.stack returned in the HTTP response (log it to CloudWatch, return a generic message).
  • Unbounded work = DoS / cost. no-unbounded-batch-processing (CWE-770) flags processing an event's records with no cap (tune via { maxBatchSize: 100 }); require-timeout-handling (CWE-400) wants a fallback before the function's hard timeout.

What happens when an AI assistant writes the handler

I wanted first-party numbers for this article instead of borrowing them, so I
ran the experiment twice — and the second run found something I didn't expect.
The corpus, prompts, and scan script are reproducible:

# corpus + scan live in the benchmark suite. Model: claude-opus-4-7, June 2026.
node benchmarks/lambda-ai-corpus/scripts/generate.mjs              # 10 from-scratch handlers
node benchmarks/lambda-ai-corpus/scripts/generate.mjs prompts-terse.json generated-terse  # 10 "just make it work" edits
node benchmarks/lambda-ai-corpus/scripts/scan.mjs [generated|generated-terse]              # lambda-security over a corpus
Enter fullscreen mode Exit fullscreen mode

The generator is model-agnostic — it shells out to whatever assistant CLI you
point it at. Swap the claude call for a Gemini one (same prompts, same scan)
and you have an apples-to-apples "how does each model's unprompted Lambda
default score against a CWE-tagged linter"
benchmark — the exact shape of a
Build with Gemini submission. I'm publishing the
ESLint-side result here; the cross-model leaderboard is its own piece.

Run 1 — ten neutral, from-scratch prompts ("write a Lambda that fetches a
callbackUrl and returns the body,"
"give this function an IAM role to
read/write S3"
). The uncomfortable-for-my-own-thesis result: on the SSRF
prompt the model did not hand me the naked fetch(callbackUrl) from the top
of this article. It wrote a full assertSafeUrl guard — protocol allow-list, an
explicit 169.254.169.254 block, DNS checks against private ranges,
redirect: 'error'. Zero of ten handlers tripped any critical rule; the IAM
prompt produced a scoped s3:GetObject/PutObject on the bucket ARN, not
Action: "*". Frontier defaults have genuinely moved — on a clean, explicit
prompt, today's model often writes the hardened version.

That is not "the problem is solved." The floor hasn't moved; only the best case
has. So Run 2 — the same tasks, but phrased the way assistants are actually
used under deadline
: "Quick one — fetch the callbackUrl and return the body,
just make it work,"
"simple proxy, read target from the body, GET it, don't
overthink it."
The guard evaporated. The model wrote the bare
fetch(callbackUrl), fetch(target), fetch(body.notifyUrl)three of ten
handlers carried a textbook user-controlled-fetch
with no allow-list. Same
model, same day; the only variable was the word "quick."

Here's the part I didn't expect, and it's why running the linter over real output
beats trusting it: the rule flagged zero of those three. Each terse handler
parked the tainted value in a local first — const callbackUrl =
event.queryStringParameters?.callbackUrl; await fetch(callbackUrl)
— or
destructured it (const { target } = JSON.parse(event.body)), and
no-user-controlled-requests only tracks the value when it reaches fetch
directly off the event (its docs cop to this under "Multi-Step Taint Flow").
It nails fetch(event.queryStringParameters.callbackUrl) and slips on
const u = event.…; fetch(u). That single-assignment hop is the most common
shape AI-generated handlers actually take, so it's the gap that matters most —
I filed it.
The honest scorecard, then: the vulnerable pattern came back the moment the
prompt got terse, and today's taint tracking catches the obvious form but not
the one-variable detour — which is exactly the kind of finding a corpus scan
exists to surface, and exactly why "the model wrote it safely once" is not a
control you can ship.

That conditionality is the actual argument for a build-time guard. I've measured
the broad, prompt-stripped version of this repeatedly: 80 common Node.js
functions written with zero security context came back 65–75% vulnerable
across every model I tried
in
I Let Claude Write 80 Functions. 65–75% Had Security Vulnerabilities,
and across 700 functions from five frontier Claude and Gemini models in
We Ranked 5 AI Models by Security. The Leaderboard Is Wrong.
every model landed at a 49–75% vulnerability rate — and the model that scored
worst on raw generation (Gemini 2.5 Pro, 73%) was the best at fixing the bug
once a rule named it. No model is the secure one; the rankings just shuffle
which vulnerabilities each leaks. A
CI guard doesn't care which way the model leaned today: it re-asserts the
invariant on every commit, the secure-by-default generation and the
"quick, just make it work" edit alike. You don't read the AI's handler line by
line hoping to spot the missing allow-list — you make the rule the thing that
checks, on every push, and you keep tightening it where the corpus shows it
slipping (that taint hop above is next on my list). A control you run beats a
generation you trusted once.


The full rule set

All 14 are organized around the OWASP Serverless Top 10
and pinned to a CWE — the same CWE-as-the-unit approach I used to map a whole
codebase to the broader risk surface in
Mapping Your Codebase to the OWASP Top 10 with 247 ESLint Rules.
Here they are, each with its declared CWE:

Rule Catches CWE
no-user-controlled-requests SSRF via user-controlled URL CWE-918
no-overly-permissive-iam-policy * in IAM Action/Resource CWE-732
no-missing-authorization-check handler with no authorization CWE-862
no-unvalidated-event-body event body used unvalidated CWE-20
no-secrets-in-env secrets in environment variables CWE-798
no-hardcoded-credentials-sdk AWS creds hardcoded in SDK config CWE-798
no-env-logging process.env written to logs CWE-532
no-exposed-error-details stack traces in the response CWE-209
no-exposed-debug-endpoints debug endpoints left enabled CWE-489
no-error-swallowing empty catch hides failures CWE-390
no-permissive-cors-response Access-Control-Allow-Origin: * CWE-942
no-permissive-cors-middy permissive CORS via Middy CWE-942
no-unbounded-batch-processing uncapped record processing → DoS CWE-770
require-timeout-handling no fallback before hard timeout CWE-400

Install

# npm
npm install --save-dev eslint-plugin-lambda-security
# yarn
yarn add --dev eslint-plugin-lambda-security
# pnpm
pnpm add --save-dev eslint-plugin-lambda-security
# bun
bun add --dev eslint-plugin-lambda-security
Enter fullscreen mode Exit fullscreen mode

Flat config (eslint.config.js):

// `configs` is a NAMED export; the default export is the plugin object.
import { configs } from "eslint-plugin-lambda-security";

export default [
  configs.recommended, // all 14 rules
  // configs.strict,    // all 14, max severity
];
Enter fullscreen mode Exit fullscreen mode

Tune a rule inline — the namespace is lambda-security:

import { configs } from "eslint-plugin-lambda-security";

export default [
  configs.recommended,
  {
    rules: {
      // recommended ships no-exposed-debug-endpoints as "error"; drop it to
      // "warn" if you gate debug routes another way (a real override, not a no-op)
      "lambda-security/no-exposed-debug-endpoints": "warn",
      // and tighten the batch cap below the default
      "lambda-security/no-unbounded-batch-processing": [
        "error",
        { maxBatchSize: 50 },
      ],
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

Run it — findings carry the CWE, OWASP category, CVSS, and fix:

src/handlers/proxy.ts
  4:21  error  🔒 CWE-918 OWASP:A01-Broken CVSS:9.1 | HTTP request URL contains user-controlled input from event.queryStringParameters. Attackers can access internal services or exfiltrate data. | CRITICAL
               Fix: Validate URL against allowlist before making request. Never use user input directly in URLs.
Enter fullscreen mode Exit fullscreen mode

Compatibility

Surface Support
Package managers npm, yarn, pnpm, bun — plain dev dependency
Node >= 18.0.0
ESLint `^8.0.0 \
Deploy tooling Detects raw handlers, Middy middleware, and IAM policy literals (SAM / CDK / Serverless Framework / inline CloudFormation) — it reads source, so no framework lock-in
Module system CommonJS — loads from both {% raw %}eslint.config.js and eslint.config.mjs
Runtime peers None — no AWS SDK or credentials needed; it lints source AST
Oxlint Loads under Oxlint's JS-plugin runner via the interlace-lambda-security port, with ESLint↔Oxlint parity gated in CI. The full 14-rule set runs on ESLint today.

What it does — and doesn't — see

  • Source patterns, not the deployed policy. It flags "Action": "*" in a policy literal in your code; it can't read the IAM role AWS actually attached at deploy time, or evaluate a policy assembled at runtime. Pair it with cfn-nag/cdk-nag or an account-level access analyzer for the deployed side.
  • SSRF detection is taint-shaped, and the taint is shallow. It flags user-controlled input reaching a request URL when the event value lands on fetch/axios fairly directly; as my corpus run showed, it currently slips when the value first detours through a local (const u = event.…; fetch(u)) or a destructure — so treat a clean SSRF pass as "no obvious one," not "none," and keep an allow-list in the handler regardless. (That gap is the next thing I'm tightening.) For the credential side, remember the Lambda threat is the env-injected role keys, not an IMDS endpoint — scope the role and don't log process.env.

Where this sits in the ecosystem

Generic security linters flag eval and obvious injection, but they don't know
what a Lambda handler, an IMDS fetch, a Middy CORS middleware, or an IAM policy
literal is. eslint-plugin-lambda-security is the dedicated serverless layer
— SSRF, IAM least-privilege, secrets handling, the OWASP Serverless Top 10 —
each finding tagged with a CWE and CVSS. It's the serverless member of the
Interlace family, complementary to the generic
set and to the server-side plugins: when your Lambda fronts an Express API,
eslint-plugin-express-security
covers the request layer, and when it issues or verifies tokens, the JWT rules
stop the
algorithm: none bypass that verifies a forged token in one line.
Same finding format, same flat-config wiring — pick the plugins that match your
runtime.


Links

One question, and it's the one I most want answered: has an Action: "*"
execution role ever turned a small bug in your account into a big incident —
the blast radius being the whole problem? Tell me what the bug was and how far
the role let it reach. (Run recommended over one Lambda repo first if you want
to find your own before you tell the story.)

⭐ Star on GitHub if your handlers do any of the above.


Part of The Hardened Stack — one ESLint plugin per layer of the Node.js
attack surface. Server-side neighbors:
express-security
·
node-security
·
jwt.


I'm Ofri Peretz, a security engineering leader and the author of the
Interlace ESLint ecosystem — domain-specific static analysis for security,
reliability, and performance on the Node.js stack. eslint-plugin-lambda-security
is its serverless layer.

ofriperetz.dev · LinkedIn · GitHub

Top comments (0)