← Back

SAS-9: Serverless Business Logic Manipulation - Code Examples

Each pair below shows a vulnerable serverless workflow step and the secure version of the same step, in both Node.js and Python. The examples cover the core failures of this category—fulfilment that trusts its upstream, non-idempotent handlers under at-least-once delivery, tampered intermediate state, forged events, and check-then-act races—plus a Step Functions state machine and a least-privilege serverless.yml. Every secure version does the same two things: re-establish its own preconditions and make its effect safe to repeat.

Example 1: Fulfilment That Trusts the Upstream → Payment Bypass

Vulnerable (Node.js)

// fulfilOrder is invoked as a later step. It ASSUMES payment already ran.
exports.handler = async (event) => {
  const { orderId, items } = event;

  // No re-check: if this function is reached at all, it ships.
  // Direct invoke, forged event, or replay all bypass validation + payment.
  await ship(orderId, items);
  return { ok: true };
};

Secure (Node.js)

// fulfilOrder re-establishes payment/authz/state itself -- it trusts nothing.
exports.handler = async (event) => {
  const orderId = requireString(event.orderId);

  // 1. Re-read authoritative state written only by the payment step.
  const order = await getOrder(orderId);
  if (!order) throw new Error('Unknown order');

  // 2. Re-verify the required preconditions HERE, at fulfilment time.
  if (order.status !== 'PAID')      throw new Error('Not paid');
  if (order.fraudReview !== 'PASS') throw new Error('Fraud review incomplete');

  // 3. Idempotent: never ship the same order twice.
  if (order.fulfilled === true) return { ok: true, note: 'already fulfilled' };

  // 4. Confirm the invoking step/principal may fulfil this order.
  await assertAuthorized(event.principal, 'order:fulfil', orderId);

  await ship(orderId, order.items);          // items from trusted state, not event
  await markFulfilled(orderId);              // atomic compare-and-set on status
  return { ok: true };
};

Vulnerable (Python)

def handler(event, context):
    order_id = event["orderId"]
    # Trusts a boolean the CALLER supplied -- attacker just sets paid=True.
    if event.get("paid"):
        ship(order_id, event["items"])
    return {"ok": True}

Secure (Python)

def handler(event, context):
    order_id = require_str(event, "orderId")

    # 1. Authoritative state, not a caller-supplied flag.
    order = get_order(order_id)
    if order is None:
        raise ValueError("Unknown order")

    # 2. Re-verify preconditions at the point of effect.
    if order["status"] != "PAID":
        raise ValueError("Not paid")
    if order["fraud_review"] != "PASS":
        raise ValueError("Fraud review incomplete")

    # 3. Idempotent fulfilment.
    if order.get("fulfilled"):
        return {"ok": True, "note": "already fulfilled"}

    assert_authorized(event.get("principal"), "order:fulfil", order_id)
    ship(order_id, order["items"])           # trusted state, not event input
    mark_fulfilled(order_id)                 # conditional update on status
    return {"ok": True}

Example 2: Non-Idempotent Handler → Duplicate-Processing Double-Spend

Vulnerable (Node.js)

// SQS/SNS are AT-LEAST-ONCE. This credits the wallet every delivery.
exports.handler = async (event) => {
  for (const rec of event.Records) {
    const { userId, amount } = JSON.parse(rec.body);
    await creditWallet(userId, amount);   // duplicate delivery -> double credit
  }
  return { ok: true };
};

Secure (Node.js)

const { DynamoDBClient, PutItemCommand } = require('@aws-sdk/client-dynamodb');
const ddb = new DynamoDBClient({});

// Insert the idempotency key ONLY if unseen; effect runs at most once.
async function claim(idemKey) {
  try {
    await ddb.send(new PutItemCommand({
      TableName: 'processed_events',
      Item: { k: { S: idemKey } },
      ConditionExpression: 'attribute_not_exists(k)',
    }));
    return true;                            // first time
  } catch (e) {
    if (e.name === 'ConditionalCheckFailedException') return false; // duplicate
    throw e;
  }
}

exports.handler = async (event) => {
  for (const rec of event.Records) {
    const { userId, amount } = JSON.parse(rec.body);
    // Key bound to the message identity + operation, not a reusable client value.
    const idemKey = `credit:${userId}:${rec.messageId}`;
    if (!(await claim(idemKey))) continue;  // duplicate -> no-op
    await creditWallet(userId, amount);     // exactly once
  }
  return { ok: true };
};

Vulnerable (Python)

def handler(event, context):
    for rec in event["Records"]:
        msg = json.loads(rec["body"])
        # Replays and platform retries each redeem the coupon again.
        redeem_coupon(msg["userId"], msg["code"])
    return {"ok": True}

Secure (Python)

import json, boto3
from botocore.exceptions import ClientError

ddb = boto3.client("dynamodb")

def claim(idem_key: str) -> bool:
    try:
        ddb.put_item(
            TableName="processed_events",
            Item={"k": {"S": idem_key}},
            ConditionExpression="attribute_not_exists(k)",
        )
        return True                          # won the race -> first time
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return False                     # already processed
        raise

def handler(event, context):
    for rec in event["Records"]:
        msg = json.loads(rec["body"])
        idem_key = f"redeem:{msg['userId']}:{msg['code']}:{rec['messageId']}"
        if not claim(idem_key):
            continue                         # duplicate ignored
        redeem_coupon(msg["userId"], msg["code"])   # exactly once
    return {"ok": True}

Example 3: Tampered Intermediate State → Forged Precondition

Vulnerable (Python)

# grantAccess gates on a DynamoDB flag. Any path that can write the flag
# (broad IAM, a separate endpoint) forges the "approved" precondition.
def handler(event, context):
    user_id = event["userId"]
    item = get_item("access_requests", user_id)
    if item.get("approved") is True:        # trusts a tamperable flag
        provision_admin(user_id)
    return {"ok": True}

Secure (Python)

# Trust the flag only if it was set by the approval step AND is signed,
# and re-derive authorization from the authoritative approval record.
import hmac, hashlib, os

SECRET = os.environ["STATE_SIGNING_KEY"].encode()

def verify(fields: dict, sig: str) -> bool:
    body = f"{fields['userId']}|{fields['approved']}|{fields['approver']}".encode()
    return hmac.compare_digest(hmac.new(SECRET, body, hashlib.sha256).hexdigest(), sig)

def handler(event, context):
    user_id = require_str(event, "userId")
    item = get_item("access_requests", user_id)

    # 1. There must be a real approval record with an integrity signature.
    if not item or item.get("approved") is not True:
        raise PermissionError("No approval on record")
    if not verify(item, item.get("sig", "")):
        raise PermissionError("Approval flag failed integrity check")

    # 2. Re-check policy: is this approver allowed to grant THIS access?
    if not approver_may_grant(item["approver"], user_id, item["tier"]):
        raise PermissionError("Approver not authorized for this grant")

    provision(user_id, item["tier"])         # tier from the signed record
    return {"ok": True}

Example 4: Forged / Replayed Event → Source and Integrity Verification

Vulnerable (Node.js)

// Consumer trusts any message on the topic as a real "order.paid" signal.
exports.handler = async (event) => {
  for (const rec of event.Records) {
    const msg = JSON.parse(rec.body);
    if (msg.type === 'order.paid') {
      await fulfil(msg.orderId);            // forged publish -> free fulfilment
    }
  }
};

Secure (Node.js)

exports.handler = async (event) => {
  for (const rec of event.Records) {
    // 1. Confirm the event source ARN is the expected queue.
    if (rec.eventSourceARN !== process.env.EXPECTED_QUEUE_ARN) {
      throw new Error('Unexpected event source');
    }
    const msg = JSON.parse(rec.body);

    // 2. Verify the producer's signature -- rejects forged/tampered events.
    if (!verifySignature(msg.payload, msg.sig)) {
      throw new Error('Unsigned or tampered event');
    }

    // 3. Idempotency guard -- rejects replays of a genuine event.
    if (!(await claim(`fulfil:${msg.payload.orderId}:${rec.messageId}`))) continue;

    // 4. STILL re-check authoritative state -- never fulfil on the event alone.
    const order = await getOrder(msg.payload.orderId);
    if (order?.status !== 'PAID') throw new Error('Not paid');

    await fulfil(order.orderId);
  }
};

Example 5: Check-Then-Act Race → Atomic Compare-and-Set

Vulnerable (Python)

# Single-use coupon, but the check and the write are not atomic.
def handler(event, context):
    code = event["code"]
    coupon = get_coupon(code)
    if not coupon["used"]:                  # 20 concurrent runs all read False
        apply_discount(event["orderId"], coupon["value"])
        set_used(code, True)                # ...then all set it True
    return {"ok": True}

Secure (Python)

# One atomic conditional update decides the single winner.
from botocore.exceptions import ClientError

def handler(event, context):
    code = require_str(event, "code")
    try:
        # Flip used False -> True only if it is still False. Exactly one wins.
        ddb.update_item(
            TableName="coupons",
            Key={"code": {"S": code}},
            UpdateExpression="SET used = :t",
            ConditionExpression="used = :f",
            ExpressionAttributeValues={":t": {"BOOL": True}, ":f": {"BOOL": False}},
        )
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return {"ok": True, "note": "coupon already used"}   # losers no-op
        raise
    apply_discount(event["orderId"], get_coupon(code)["value"])  # only the winner
    return {"ok": True}

Example 6: Server-Side Orchestration (Step Functions)

Let the state machine own the sequence so the happy path cannot skip a step. Each task Lambda still re-validates (Examples 1–5); the orchestrator removes the client from deciding order.

Secure (Amazon States Language)

{
  "Comment": "Checkout: order enforced server-side, gated on real payment.",
  "StartAt": "ValidateCart",
  "States": {
    "ValidateCart":  { "Type": "Task", "Resource": "arn:aws:lambda:...:validateCart",
                       "Next": "RunFraudCheck" },
    "RunFraudCheck": { "Type": "Task", "Resource": "arn:aws:lambda:...:runFraudCheck",
                       "Next": "ChargePayment" },
    "ChargePayment": { "Type": "Task", "Resource": "arn:aws:lambda:...:chargePayment",
                       "ResultPath": "$.payment", "Next": "PaidGate" },
    "PaidGate": {
      "Type": "Choice",
      "Choices": [
        { "Variable": "$.payment.status", "StringEquals": "CAPTURED",
          "Next": "FulfilOrder" }
      ],
      "Default": "FailOrder"
    },
    "FulfilOrder": { "Type": "Task", "Resource": "arn:aws:lambda:...:fulfilOrder",
                     "End": true },
    "FailOrder":   { "Type": "Fail", "Error": "PaymentNotCaptured",
                     "Cause": "Fulfilment blocked: payment not captured" }
  }
}

Example 7: Least-Privilege Invocation in serverless.yml

Server-side orchestration only helps if the internal steps cannot be invoked out of band. Give fulfilOrder no public URL and allow only Step Functions to invoke it, and scope each function's execution role tightly.

Vulnerable (over-exposed, over-privileged)

service: checkout
provider:
  name: aws
  runtime: nodejs20.x
  iam:
    role:
      statements:
        - Effect: Allow
          Action: "*"            # every action...
          Resource: "*"          # ...on everything. A foothold = takeover.

functions:
  fulfilOrder:
    handler: src/fulfil.handler
    url: true                    # PUBLIC Function URL -- anyone can invoke it
    events:
      - sns: arn:aws:sns:us-east-1:111122223333:order-events   # + open topic

Secure (scoped invocation and per-function role)

service: checkout
provider:
  name: aws
  runtime: nodejs20.x
  # No account-wide wildcard role.

functions:
  fulfilOrder:
    handler: src/fulfil.handler
    # No 'url: true'; no public http/sns trigger on this internal step.
    iamRoleStatements:
      - Effect: Allow
        Action: [ "dynamodb:GetItem", "dynamodb:UpdateItem" ]
        Resource: "arn:aws:dynamodb:*:*:table/orders"     # this table only
      # Note: no lambda:InvokeFunction, no sns:Publish, no wildcard.

resources:
  Resources:
    FulfilPermission:
      Type: AWS::Lambda::Permission
      Properties:
        FunctionName: !Ref FulfilOrderLambdaFunction
        Action: lambda:InvokeFunction
        Principal: states.amazonaws.com                   # ONLY Step Functions
        SourceArn: !GetAtt CheckoutStateMachine.Arn

What Changed, and Why

ManipulationVulnerableSecure
Direct invoke / skip stepsShips if reached at allRe-reads state; requires PAID + fraud PASS + authz
Caller-supplied preconditionTrusts event.paidIgnores it; uses authoritative order state
Duplicate deliveryCredits/redeems every deliveryIdempotency key + conditional insert → exactly once
Tampered state flagTrusts approved=trueRequires signed approval + re-checked policy
Forged / replayed eventActs on any topic messageVerifies source ARN + signature + dedup + state
Check-then-act raceNon-atomic check then writeSingle atomic compare-and-set picks one winner
Out-of-band invocationPublic URL + wildcard roleStep Functions-only invoke + least privilege

Every secure step does the same two things in order: re-establish its own preconditions (authorization + required prior state, verified not assumed), then make its effect idempotent so at-least-once delivery cannot double-count it. Server-side orchestration and scoped invocation are the backstops that keep the steps on their intended path.

Next Steps