Skip to content

parseBody misses a canonically-cased Content-Type header, silently passing an unparsed body to handlers #379

Description

@hoegertn

What happens

parseBody in src/lambda/handler.ts only JSON-parses a request body when it finds the
header key exactly content-type (lowercase):

function parseBody(event) {
  let body = event.body;
  if (event.body && event.isBase64Encoded) {
    const buff = Buffer.from(event.body, 'base64');
    body = buff.toString('utf8');
  }
  // If the request body is in JSON format, parse it into a JavaScript object
  if (event.headers && event.headers['content-type']?.includes('application/json')) {
    return JSON.parse(body ?? '{}');
  }
  // If the request body is not present or not in JSON format, return it as-is
  return body;
}

API Gateway REST (v1) proxy integrations pass request headers through with the client's
original casing
. A client sending the conventional

Content-Type: application/json

therefore misses this lookup, and the handler receives the body as a raw string instead of
the parsed object its type declares.

Why it is easy to miss

createOpenApiHandlerWithRequestBody<OP> types the second handler argument as
OP['requestBody']['content']['application/json'] — an object. So the handler compiles, and at
runtime it silently receives a string. Any handler that reads fields off that argument sees
undefined for all of them.

In our case (a PUT that records a sports result) this produced a 200/204 response with the
submitted data silently discarded
— the worst failure mode, because the client is told it
succeeded. There is no error, no log line, and nothing to correlate later.

Reproduction

export const handler = api.createOpenApiHandlerWithRequestBodyNoResponse<operations['updateThing']>(
  async (ctx, body) => {
    ctx.logger.info(typeof body);   // 'object' with `content-type`, 'string' with `Content-Type`
    ctx.response.statusCode = 204;
  },
);

Invoke twice with the same JSON payload, varying only the header casing.

Directly against the test utility:

await new LambdaRestUnitTest(handler).call({
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },   // canonical casing
  body: JSON.stringify({ present: true }),
});

Suggested fix

Look the header up case-insensitively, e.g.:

function headerValue(headers: Record<string, string | undefined> | null, name: string) {
  if (!headers) return undefined;
  const key = Object.keys(headers).find(k => k.toLowerCase() === name.toLowerCase());
  return key ? headers[key] : undefined;
}

if (headerValue(event.headers, 'content-type')?.includes('application/json')) {
  return JSON.parse(body ?? '{}');
}

HTTP header names are case-insensitive per RFC 9110 §5.1, so any lookup against
event.headers needs this treatment. multiValueHeaders has the same property.

Two things worth considering alongside it:

  1. Fail loudly rather than passing a string through. When the operation's contract declares
    an application/json request body but the incoming body cannot be parsed as JSON, a 400
    would be safer than handing the handler a value that does not match its declared type.
  2. LambdaRestUnitTest inherits the behaviour, so tests written with the canonical casing
    reproduce it — which is how we found it.

Workaround

Normalising defensively in application code:

export function parseRequestBody<T extends object>(body: unknown): Partial<T> {
  if (!body) return {};
  if (typeof body === 'string') {
    try {
      const parsed = JSON.parse(body);
      return typeof parsed === 'object' && parsed !== null ? parsed as Partial<T> : {};
    } catch {
      return {};
    }
  }
  return typeof body === 'object' ? body as Partial<T> : {};
}

Environment

  • cdk-serverless 3.0.0
  • API Gateway REST API (SpecRestApi) with a Lambda proxy integration
  • Node.js 22

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions