Back

SAS-10: Improper Exception Handling and Verbose Error Messages - Prevention

The fix for SAS-10 is a single, repeatable discipline applied to every function: catch the error, decide deliberately what happens next, tell the caller nothing internal, and tell the logs everything. This page turns that sentence into concrete controls—handler wrappers, API Gateway error mapping, fail-closed security paths, idempotent retries, and uniform responses—so no exception is ever left to the platform's hostile defaults.

Guiding Principles

The platform default is: surface the raw error and retry. Every principle below is about overriding one half of that default deliberately.

1. Wrap Every Handler

Give each function one outermost boundary that turns any exception into a controlled, generic response. A shared wrapper (middleware/decorator) makes this consistent across a large fleet of small functions.

// Node.js: a reusable wrapper applied to every handler
const withErrorBoundary = (fn) => async (event, context) => {
  const correlationId = context.awsRequestId;
  try {
    return await fn(event, context);
  } catch (err) {
    // FULL detail -> server-side logs only, keyed by correlationId
    console.error(JSON.stringify({
      level: 'ERROR', correlationId,
      name: err.name, message: err.message, stack: err.stack,
      function: context.functionName
    }));
    // GENERIC detail -> caller
    return {
      statusCode: 500,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        error: 'internal_error',
        message: 'The request could not be processed.',
        correlationId              // let support correlate, leak nothing
      })
    };
  }
};

exports.handler = withErrorBoundary(async (event, context) => {
  // ... business logic; may throw freely, the boundary handles it ...
});

The correlation id is the bridge: it is safe to show the caller and lets an operator find the full trace in the logs without any internal detail crossing the wire.

2. Two Audiences: Generic Out, Detailed In

Design the response and the log as two separate artifacts with opposite goals.

Caller ResponseServer-Side Log
GoalReveal nothing internalCapture everything to debug
MessageGeneric, stable, uniformExact exception + stack
IdentifiersCorrelation id onlyCorrelation id + request/trace ids
SecretsNeverNever (redacted)
FormatSmall JSONStructured JSON for querying

Handle known error types explicitly and map them to safe client statuses; let everything unexpected fall to a single generic 500.

# Python: classify known errors, generic fallback for the rest
def handler(event, context):
    try:
        return _run(event, context)
    except ValidationError as e:
        log.warning({"kind": "validation", "detail": str(e),
                     "correlation_id": context.aws_request_id})
        return client_error(400, "invalid_request",
                            "One or more fields are invalid.", context)
    except NotAuthorized:
        return client_error(403, "forbidden", "Access denied.", context)
    except Exception as e:                       # unexpected -> generic
        log.error({"kind": "unhandled", "type": type(e).__name__,
                   "message": str(e), "correlation_id": context.aws_request_id},
                  exc_info=True)                 # full stack to logs only
        return client_error(500, "internal_error",
                            "The request could not be processed.", context)

3. Map Errors at API Gateway

Even with a handler boundary, configure the gateway as a second wall so an unexpected function crash (timeout, OOM, init failure) is never returned raw. Use gateway error responses / integration mappings to replace internal errors with generic bodies.

# AWS SAM / OpenAPI: gateway-level generic responses
# (illustrative; keeps runtime errors from reaching the client verbatim)
Resources:
  Api:
    Type: AWS::Serverless::Api
    Properties:
      DefinitionBody:
        x-amazon-apigateway-gateway-responses:
          DEFAULT_5XX:
            responseTemplates:
              application/json: '{"error":"internal_error","message":"The request could not be processed."}'
          DEFAULT_4XX:
            responseTemplates:
              application/json: '{"error":"bad_request","message":"The request was invalid."}'

4. Keep Secrets and Stack Traces Out

Two rules, no exceptions: never serialise the environment or config into an error, and redact sensitive fields before anything is logged.

// Redact before logging; never spread `process.env` or the raw event
const REDACT = new Set(['authorization','password','token','secret','ssn','card']);
const redact = (obj) => Object.fromEntries(
  Object.entries(obj || {}).map(([k, v]) =>
    [k, REDACT.has(k.toLowerCase()) ? '***' : v])
);

// GOOD: log selected, redacted context
console.error(JSON.stringify({ level:'ERROR', correlationId,
  input: redact(safeSubsetOfEvent) }));

// NEVER: console.error(err, process.env)      // dumps secrets
// NEVER: return { body: JSON.stringify(err) } // dumps stack + fields

5. Fail Closed on Security Paths

For authentication, authorization, signature checks, and validation, the failure branch must deny. Never let a caught exception drop through to the allowed path.

# WRONG: fail-open
try:
    claims = verify_token(hdr)
except Exception:
    claims = {}            # error -> empty claims -> treated as allowed later

# RIGHT: fail-closed
try:
    claims = verify_token(hdr)
except Exception as e:
    log.warning({"event":"auth_error","reason":type(e).__name__,
                 "correlation_id": ctx.aws_request_id})
    return client_error(401, "unauthorized", "Authentication required.", ctx)
if claims.get("role") != "admin":
    return client_error(403, "forbidden", "Access denied.", ctx)
return admin_action(event)   # reached only on explicit success

6. Idempotency and Cleanup on Error

Assume at-least-once delivery. An error after a side effect will be retried, so make side effects safe to repeat and roll back partial work.

// Idempotency key makes a repeated charge a no-op
exports.handler = withErrorBoundary(async (event) => {
  const key = event.messageId;                 // stable per logical event
  if (await alreadyProcessed(key)) return ok(); // dedupe on retry

  await withCleanup(async () => {
    await chargeCard(event.orderId, event.amount, { idempotencyKey: key });
    await markPaid(event.orderId);
    await recordProcessed(key);                // commit the dedupe marker last
  }, /* onError */ async () => {
    await compensate(event.orderId);           // undo partial state
  });
});

7. Uniform Responses (No Oracles)

Equivalent failures must look—and take—the same from the outside.

// Same outward result whether or not the account exists
async function login(email, password) {
  const user = await findUser(email);          // may be null
  const ok = await constantTimeVerify(password, user?.hash ?? DUMMY_HASH);
  if (!user || !ok) return unauthorized();      // identical response + timing
  return session(user);
}

8. Guarantee the Audit Trail

The handler boundary must log before it returns, so a failure can never erase the record of what happened.

9. Disable Verbose Output in Production

Prevention Checklist

ControlWhat It Prevents
Outermost handler boundary on every functionRaw errors reaching the caller
Generic client message + correlation idStack-trace / internals disclosure
API Gateway / Function URL error mappingCrash/timeout errors leaking verbatim
No env/config serialised; logs redactedSecret and infrastructure exposure
Fail-closed security branchesAuthorization / validation bypass
Idempotency keys + compensationDuplicate side effects, partial state
Uniform responses + constant-time checksEnumeration and timing oracles
Log-before-return in the boundaryLost audit trail on crash
Debug/verbose output off in productionSecondary leaks via logs
Explicit known-error handling + generic fallbackInconsistent, leaky error shapes

Bottom line: every function should be boring when it fails—a generic message to the caller, a rich structured record in the logs, a hard deny on security paths, and a safe-to-retry side effect. Boring failures are secure failures.

Next Steps