API Schema Validation as a Security Control: OpenAPI Enforcement and the Mass Assignment Problem
The Problem
In 2012, Egor Homakov submitted a pull request to Rails that demonstrated how GitHub’s mass assignment vulnerability allowed any authenticated user to add their SSH key to any repository’s deploy keys — without permission. He did it by passing public_key[user_id]=4223 in a POST body that the controller mapped directly to the PublicKey model. The field wasn’t listed as forbidden; it simply wasn’t listed at all. GitHub patched it within hours. The underlying pattern — application code that automatically maps request fields to internal model attributes without an explicit allowlist — remains one of the most reliably exploitable vulnerability classes in web APIs, fifteen years later.
Mass assignment goes by several names depending on the framework: auto-binding in Spring MVC, attribute mass assignment in Rails, direct hydration in Laravel. The mechanism is the same in all of them. A developer writes a handler that deserialises a JSON body and passes it to an ORM or model constructor. The framework helpfully maps every field in the body to every attribute it can find on the target model. The developer tests with the fields the feature requires. The fields the attacker will add — role, is_admin, is_staff, plan, verified, subscription_tier — are never tested, because from the attacker’s perspective they don’t need to be. They just need to be present on the model.
# VULNERABLE: Django-style mass assignment
class UserUpdateView(APIView):
def patch(self, request, user_id):
user = User.objects.get(id=user_id)
# Maps ALL fields from the request body to the model instance
# No allowlist. No rejection of unexpected fields.
user.__dict__.update(request.data)
user.save()
# Legitimate request — what the developer tested:
# PATCH /users/123
# {"name": "Alice", "email": "alice@example.com"}
# Attack request — what the developer didn't test:
# PATCH /users/123
# {"name": "Alice", "email": "alice@example.com",
# "role": "admin", "is_staff": true, "subscription_tier": "enterprise",
# "verified": true, "email_confirmed_at": "2020-01-01T00:00:00Z"}
# Result: Alice is now an admin with a free enterprise subscription
The attack requires no knowledge of internal implementation details beyond what the model’s field names likely are — and those are predictable. They follow conventional naming patterns (is_admin, admin, role, plan, tier, verified, staff), they appear in error messages when you send malformed requests, and they sometimes appear in OpenAPI specs that describe other endpoints. A blind scan of common field names against a profile update endpoint takes minutes and costs nothing.
Real incident pattern: HackerOne disclosed mass assignment vulnerabilities against Shopify (2019), GitLab (2020), and multiple SaaS platforms where users could elevate their own privileges or modify subscription tiers by including extra fields in account update requests. In each case the fix was the same: add an explicit allowlist of fields that a given endpoint is permitted to modify.
The allowlist can live in the application, and for defence-in-depth purposes it should. But application-layer allowlists have a structural failure mode: application teams forget to update them when adding new fields, validation is frequently omitted from “internal” endpoints that developers assume won’t be externally accessible, and inconsistent validation across API versions creates exploitable gaps between v1 and v2 of the same endpoint. A security engineer reviewing 40 microservices cannot audit the allowlist logic in every handler of every service and trust that it stays correct as the codebase evolves.
Gateway-level schema validation catches all endpoints uniformly, validates before the request reaches any application code, and is enforced from a single configuration source that security teams control independently of application developers.
Threat Model
Mass assignment via unexpected fields → privilege escalation. An attacker sends a PATCH request to any user-writable endpoint with additional fields that correspond to privileged model attributes. If the application maps request fields to model attributes without an explicit allowlist, the attacker self-escalates to admin, modifies their subscription tier, or marks their account verified. The attack requires only a valid session token — no injection, no memory corruption.
Oversized payload → application memory exhaustion → DoS. Without payload size limits, a client can send a 500MB JSON body to any endpoint that accepts request bodies. The application deserialises the full payload before any validation logic runs. At sufficient volume, this exhausts heap memory and kills the process. A gateway that rejects requests exceeding a per-endpoint size limit absorbs the attack before the application is involved.
Malformed input bypassing application validation → injection. Schema validation as defence in depth: if the gateway enforces that a field must match type string with pattern ^\+[1-9]\d{1,14}$, a phone number field cannot contain SQL metacharacters or shell metacharacters regardless of what the application’s validation logic does. The schema constraint is the first filter; the application’s input handling is the second. A bypass requires compromising both layers.
Type confusion → unexpected code path. Sending an integer where a string is expected, or an array where a scalar is expected, frequently triggers code paths that were never tested because they are never reachable from the normal UI. Type confusion can bypass length checks (an integer has no length), trigger implicit string conversion that strips sanitisation, or reach error handling code that logs more than it should. Strict type validation at the schema layer eliminates type confusion before it reaches the application.
Undeclared endpoint exposure. A service adds an admin endpoint — /api/internal/users/{id}/promote — that is not in the OpenAPI spec and is therefore not covered by gateway validation. The developer intends it to be called only from a trusted internal service, but the gateway route is misconfigured and the endpoint is externally reachable. Without schema validation on that endpoint, mass assignment on a bare endpoint proceeds unchecked.
How Schema Validation Prevents Mass Assignment
JSON Schema’s additionalProperties: false constraint is the direct prevention mechanism. When a schema declares additionalProperties: false, any field in the request body that is not listed under properties causes the entire request to be rejected with a 400 response. The request never reaches the application.
The constraint has to be declared explicitly — the default behaviour of JSON Schema is to allow additional properties. A spec that lists name, email, and phone under properties but does not set additionalProperties: false validates those three fields and passes everything else through, including role and is_admin. This is the most common misconfiguration: a spec that documents the allowed fields but does not enforce their exclusivity.
Three validation layers work together:
- Type validation: field values must match declared types.
nameisstring, notintegerorarray.ageisinteger, not"twenty-three". - Presence and exclusivity validation: required fields must be present; fields not declared in
propertiesare rejected whenadditionalProperties: falseis set. This is the mass assignment prevention layer. - Value validation: enum values restrict strings to a declared set,
minLength/maxLengthbound string sizes,minimum/maximumbound numeric ranges,patternconstrains strings to a regex. These prevent injection through format constraints.
Hardening Configuration
1. OpenAPI Spec with additionalProperties: false
Every request body schema that accepts a JSON object must set additionalProperties: false. Every property must have type constraints. String fields must have maxLength. This is the spec-level declaration; the gateway enforces it.
# openapi.yaml — strict schema declarations for user endpoints
openapi: "3.0.3"
info:
title: User API
version: "1.0"
paths:
/users/{userId}:
patch:
operationId: updateUser
parameters:
- name: userId
in: path
required: true
schema:
type: string
pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UserUpdateRequest'
responses:
'200':
description: User updated
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
'400':
description: Validation error — unexpected fields or invalid values
'422':
description: Semantic validation error
components:
schemas:
UserUpdateRequest:
type: object
additionalProperties: false # Rejects any field not listed below
properties:
name:
type: string
minLength: 1
maxLength: 100
email:
type: string
format: email
maxLength: 255
phone:
type: string
pattern: "^\\+[1-9]\\d{1,14}$" # E.164 format
preferences:
$ref: '#/components/schemas/UserPreferences'
# Intentionally absent: role, is_staff, is_admin, plan, verified,
# subscription_tier, email_confirmed_at, created_at, updated_at.
# These fields are rejected at the schema level before reaching the app.
UserPreferences:
type: object
additionalProperties: false # Nested objects need this too
properties:
theme:
type: string
enum: ["light", "dark", "system"]
notifications_enabled:
type: boolean
timezone:
type: string
maxLength: 64
pattern: "^[A-Za-z_/]{1,64}$"
UserResponse:
type: object
additionalProperties: false
properties:
id:
type: string
format: uuid
name:
type: string
email:
type: string
# role, is_staff excluded from response too — response schemas matter
Note that additionalProperties: false must be applied recursively to nested object schemas. A top-level declaration covers only the root object; nested objects like preferences in the example above require their own additionalProperties: false or an attacker can use nested fields as a vector.
2. Kong Gateway Schema Validation Plugin
Kong’s request-validator plugin validates request bodies against a JSON Schema definition at the proxy layer. The plugin rejects non-conforming requests before they are proxied upstream. Deploy this plugin on every route that accepts a request body — not just routes you consider sensitive.
# kong-request-validator.yaml
# Applies JSON Schema validation at the Kong proxy layer
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
name: user-update-validator
namespace: api-gateway
plugin: request-validator
config:
# JSON Schema draft4 — Kong's supported version
body_schema: |
{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 100
},
"email": {
"type": "string",
"maxLength": 255
},
"phone": {
"type": "string",
"pattern": "^\\+[1-9]\\d{1,14}$"
}
}
}
allowed_content_types:
- "application/json"
version: draft4
verbose_response: false # Do not expose schema details in error responses
---
# Apply the plugin to the specific route
apiVersion: configuration.konghq.com/v1
kind: KongIngress
metadata:
name: user-api-route
spec:
route:
methods:
- PATCH
protocols:
- https
---
# Attach plugin to the route
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: user-api
annotations:
konghq.com/plugins: user-update-validator
konghq.com/strip-path: "true"
spec:
rules:
- http:
paths:
- path: /api/users
backend:
serviceName: user-service
servicePort: 8080
Kong’s verbose_response: false setting is important: when set to true, the validation error response includes the schema definition, which tells attackers exactly which fields are accepted and therefore which fields to look for elsewhere. The 400 response body should say "Validation failed" or similar — nothing more.
3. Envoy External Authorization Filter for Schema Validation
For service meshes using Envoy as a sidecar, schema validation can be enforced at the proxy layer via a Lua filter or an external authorisation service. The Lua filter runs inline in the Envoy data path with no additional network hop.
-- envoy-schema-validator.lua
-- Enforces field allowlists at the Envoy proxy layer
-- Deploy as an HttpConnectionManager Lua filter
local cjson = require "cjson"
-- Per-route field allowlists.
-- Keys are pattern strings; values are sets of allowed field names.
local ROUTE_ALLOWLISTS = {
{pattern = "^/api/users/[^/]+$", methods = {"PATCH"}, fields = {
name = true, email = true, phone = true
}},
{pattern = "^/api/orders$", methods = {"POST"}, fields = {
items = true, shipping_address = true, payment_token = true,
promo_code = true
}},
{pattern = "^/api/auth/login$", methods = {"POST"}, fields = {
username = true, password = true, totp_code = true
}},
}
local function match_route(path, method)
for _, rule in ipairs(ROUTE_ALLOWLISTS) do
if string.match(path, rule.pattern) then
for _, m in ipairs(rule.methods) do
if m == method then
return rule.fields
end
end
end
end
return nil
end
local function find_unexpected_fields(parsed, allowed_fields)
for field, _ in pairs(parsed) do
if not allowed_fields[field] then
return field
end
end
return nil
end
function envoy_on_request(request_handle)
local method = request_handle:headers():get(":method")
-- Only validate write requests
if method ~= "POST" and method ~= "PUT" and method ~= "PATCH" then
return
end
local content_type = request_handle:headers():get("content-type") or ""
if not string.find(content_type, "application/json") then
return
end
local path = request_handle:headers():get(":path")
-- Strip query string for pattern matching
local path_only = string.match(path, "^([^?]+)") or path
local allowed_fields = match_route(path_only, method)
if not allowed_fields then
-- No rule defined for this route — allow through (monitor, don't block)
-- Change to block-by-default by returning 400 here
return
end
local body = request_handle:body()
if not body or body:length() == 0 then
return
end
-- Enforce payload size limit: 64KB for most endpoints
if body:length() > 65536 then
request_handle:respond(
{[":status"] = "413", ["content-type"] = "application/json"},
cjson.encode({error = "Payload too large"})
)
return
end
local raw = body:getBytes(0, body:length())
local ok, parsed = pcall(cjson.decode, raw)
if not ok then
request_handle:respond(
{[":status"] = "400", ["content-type"] = "application/json"},
cjson.encode({error = "Invalid JSON"})
)
return
end
if type(parsed) ~= "table" then
request_handle:respond(
{[":status"] = "400", ["content-type"] = "application/json"},
cjson.encode({error = "Request body must be a JSON object"})
)
return
end
local unexpected = find_unexpected_fields(parsed, allowed_fields)
if unexpected then
-- Log the field name internally but do not return it in the response
request_handle:logWarn("Rejected request with unexpected field from " ..
(request_handle:headers():get("x-forwarded-for") or "unknown"))
request_handle:respond(
{[":status"] = "400", ["content-type"] = "application/json"},
cjson.encode({error = "Request contains unexpected fields"})
)
return
end
end
The Lua filter applies to every request that reaches the sidecar. The ROUTE_ALLOWLISTS table is the security-critical configuration: it must be kept in sync with the OpenAPI spec. This is the failure mode addressed by the CI check in section 6.
4. Pydantic v2 Strict Mode (Application-Layer Defence in Depth)
Gateway validation is the primary control. Application-layer validation is defence in depth — it catches anything that bypasses the gateway (direct service-to-service calls, misconfigured routes, gateway outages). Pydantic v2’s extra="forbid" and strict=True implement both mass assignment prevention and strict type checking at the model level.
# Python: Pydantic v2 — defence-in-depth schema enforcement
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
from pydantic import model_validator
import re
PHONE_PATTERN = re.compile(r"^\+[1-9]\d{1,14}$")
class UserUpdateRequest(BaseModel):
model_config = ConfigDict(
extra="forbid", # Raise ValidationError on any unknown field
strict=True, # No implicit coercion: "123" != 123
str_max_length=255, # Global maximum for all string fields
str_strip_whitespace=True,
)
name: str | None = None
email: EmailStr | None = None
phone: str | None = None
@field_validator("name")
@classmethod
def name_not_empty(cls, v: str | None) -> str | None:
if v is not None and len(v.strip()) == 0:
raise ValueError("name cannot be empty")
return v
@field_validator("phone")
@classmethod
def phone_e164(cls, v: str | None) -> str | None:
if v is not None and not PHONE_PATTERN.match(v):
raise ValueError("phone must be in E.164 format")
return v
@model_validator(mode="after")
def at_least_one_field(self) -> "UserUpdateRequest":
if self.name is None and self.email is None and self.phone is None:
raise ValueError("At least one field must be provided")
return self
# FastAPI usage
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.responses import JSONResponse
app = FastAPI()
@app.patch("/api/users/{user_id}")
async def update_user(
user_id: str,
body: UserUpdateRequest, # Pydantic validates on deserialisation
current_user: User = Depends(require_auth),
):
# body.model_dump() contains ONLY: name, email, phone
# If the client sent role=admin, it raised ValidationError → 422 response
# before this handler executed.
updates = body.model_dump(exclude_none=True)
if not updates:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="No fields to update",
)
updated = await user_repository.update(user_id, updates, actor=current_user.id)
return JSONResponse(content=updated.to_response_dict())
# Custom exception handler: don't leak field names in error messages
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
# Log the full error internally
logger.warning("Validation error", extra={"errors": exc.errors(), "path": str(request.url)})
# Return a minimal response to the client
return JSONResponse(
status_code=422,
content={"error": "Request validation failed"},
)
The strict=True configuration is worth examining separately. In Pydantic v2’s default (lax) mode, sending "true" as the value of a boolean field succeeds — Pydantic coerces the string to True. In strict mode, that raises a ValidationError. This matters because type coercion can bypass length checks (len(True) raises TypeError), and implicit coercion is exploited in type confusion attacks where sending a value of unexpected type reaches a code path that assumes the coercion already happened.
5. Payload Size Limits at the Reverse Proxy
Payload size limits must be set per endpoint type. A global limit either blocks legitimate large uploads or permits excessively large bodies on endpoints that should accept only small payloads. Set limits conservatively and expand them only for endpoints that require larger payloads.
# nginx: per-location payload size limits
# Default: deny oversized payloads before the app sees them
client_max_body_size 0; # Override per location — never use global default
server {
listen 443 ssl;
server_name api.example.com;
# Authentication endpoints: tiny — credentials only
location ~ ^/api/auth/ {
client_max_body_size 4k;
proxy_pass http://auth-service;
}
# User profile updates: small — a few fields
location ~ ^/api/users/ {
client_max_body_size 16k;
proxy_pass http://user-service;
}
# Order creation: medium — line items, address, payment token
location ~ ^/api/orders {
client_max_body_size 64k;
proxy_pass http://order-service;
}
# Document uploads: large — actual file content
location ~ ^/api/documents/upload {
client_max_body_size 10m;
proxy_pass http://document-service;
}
# Catch-all: reject if no specific rule matched
location / {
client_max_body_size 8k;
proxy_pass http://default-service;
}
}
For Kong, the request-size-limiting plugin provides equivalent per-route enforcement:
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
name: auth-size-limit
plugin: request-size-limiting
config:
allowed_payload_size: 4 # kilobytes
size_unit: kilobytes
require_content_length: true # Reject chunked transfers without Content-Length
The require_content_length: true setting deserves attention: without it, a client using chunked transfer encoding can stream data without declaring a Content-Length header, which makes size limit enforcement harder. Requiring the header allows Kong to reject oversized requests at the header-reading phase, before buffering any body bytes.
6. Automated Schema Completeness Check in CI
The gap between “schema documents the allowed fields” and “schema enforces the allowed fields” is exactly additionalProperties: false. If this constraint is missing from any request body schema, the spec is documentation without enforcement. This CI check fails the pipeline when any POST, PUT, or PATCH request body schema is missing the constraint.
# .github/workflows/api-schema-check.yaml
name: API Schema Validation
on:
push:
paths:
- 'openapi.yaml'
- 'openapi/**'
pull_request:
paths:
- 'openapi.yaml'
- 'openapi/**'
jobs:
schema-completeness:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate OpenAPI spec syntax
run: |
npm install -g @redocly/cli
redocly lint openapi.yaml --format=stylish
- name: Enforce additionalProperties: false on all request bodies
run: |
python3 << 'PYEOF'
import yaml
import sys
def check_schema(schema, path):
"""Recursively check that all object schemas have additionalProperties: false."""
issues = []
if not isinstance(schema, dict):
return issues
if schema.get("type") == "object" or "properties" in schema:
if schema.get("additionalProperties") is not False:
issues.append(f" Schema at {path}: missing additionalProperties: false")
# Check nested objects in properties
for prop_name, prop_schema in schema.get("properties", {}).items():
issues.extend(check_schema(prop_schema, f"{path}.properties.{prop_name}"))
# Check schemas referenced in allOf/anyOf/oneOf
for combiner in ("allOf", "anyOf", "oneOf"):
for i, sub in enumerate(schema.get(combiner, [])):
issues.extend(check_schema(sub, f"{path}.{combiner}[{i}]"))
return issues
def resolve_ref(spec, ref):
"""Resolve a $ref to its target schema."""
if not ref.startswith("#/"):
return {}
parts = ref.lstrip("#/").split("/")
target = spec
for part in parts:
target = target.get(part, {})
return target
with open("openapi.yaml") as f:
spec = yaml.safe_load(f)
write_methods = {"post", "put", "patch"}
all_issues = []
for path, path_item in spec.get("paths", {}).items():
for method, operation in path_item.items():
if method not in write_methods:
continue
request_body = operation.get("requestBody", {})
for content_type, content in request_body.get("content", {}).items():
schema = content.get("schema", {})
# Resolve top-level $ref
if "$ref" in schema:
schema = resolve_ref(spec, schema["$ref"])
location = f"{method.upper()} {path} ({content_type})"
issues = check_schema(schema, location)
all_issues.extend(issues)
if all_issues:
print("Schema completeness failures — missing additionalProperties: false:")
for issue in all_issues:
print(issue)
sys.exit(1)
print("All request body schemas have additionalProperties: false")
PYEOF
- name: Check for undeclared endpoints
run: |
# Verify every route in the router has a corresponding OpenAPI path
# Adjust the grep pattern for your framework's routing syntax
python3 << 'PYEOF'
import yaml
import subprocess
import re
import sys
with open("openapi.yaml") as f:
spec = yaml.safe_load(f)
spec_paths = set(spec.get("paths", {}).keys())
# Extract routes from FastAPI application (example for Python/FastAPI)
# Adjust for your framework
result = subprocess.run(
["grep", "-r", "@app\.\(get\|post\|put\|patch\|delete\)", "--include=*.py", "-h", "src/"],
capture_output=True, text=True
)
route_pattern = re.compile(r'@app\.\w+\(["\']([^"\']+)["\']')
code_routes = set()
for line in result.stdout.splitlines():
match = route_pattern.search(line)
if match:
code_routes.add(match.group(1))
undocumented = code_routes - spec_paths
if undocumented:
print("Routes in code not in OpenAPI spec (no schema validation):")
for route in sorted(undocumented):
print(f" {route}")
sys.exit(1)
print("All routes are documented in OpenAPI spec")
PYEOF
The second check — comparing routes in code against paths in the spec — catches the undeclared endpoint failure mode. A route that exists in the application but not in the spec receives no gateway-level validation. This check surfaces the gap in CI before the route reaches production.
Expected Behaviour
Pydantic extra="forbid" rejection — when a client sends {"name": "Alice", "role": "admin"} to a FastAPI endpoint backed by a Pydantic model with extra="forbid":
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{"error": "Request validation failed"}
The application logs internally: {"event": "validation_error", "errors": [{"type": "extra_forbidden", "loc": ["body", "role"], "msg": "Extra inputs are not permitted"}], "path": "/api/users/123"}. The client receives only the generic error — the field name role is not in the response, which would confirm to an attacker that the field exists on the model.
Kong request-validator rejection — when a client sends a field not in the body schema:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"message": "request body validation failed, data is invalid"}
Kong’s default verbose_response includes schema details. With verbose_response: false, the response is minimal. Check Kong’s error log for the field-level detail.
CI schema check output — when a developer adds a new endpoint without additionalProperties: false:
Schema completeness failures — missing additionalProperties: false:
Schema at POST /api/subscriptions (application/json): missing additionalProperties: false
Schema at POST /api/subscriptions (application/json).properties.billing_info: missing additionalProperties: false
Error: Process completed with exit code 1.
The PR is blocked. The developer must add additionalProperties: false to both the root schema and the nested billing_info object before the pipeline passes.
Trade-offs
additionalProperties: false breaks clients that send undeclared fields. Some older API clients, mobile apps, and third-party integrations add fields to request bodies speculatively — fields they expect to be ignored. This is technically valid JSON behaviour; JSON Schema’s default permits additional properties precisely because the spec authors considered ignore-unknown to be a reasonable default. Enforcing additionalProperties: false requires coordinating with API consumers and may require a migration period where the schema is permissive for deprecated clients while strict for new ones. Version the API and enforce strict schemas from v2 onwards.
Strict type validation prevents type coercion clients depend on. Some clients send "true" (string) where a boolean is expected, or "123" where an integer is expected, because their HTTP library serialises everything as strings. Pydantic v2’s strict=True rejects these. Gateway-level JSON Schema validation in draft4 is also strict by default for type matching. Audit existing clients before enabling strict validation — fix clients that send incorrect types rather than making the schema permissive.
Payload size limits must be calibrated per endpoint. Setting a global 10KB limit breaks file upload endpoints. Setting a global 10MB limit makes every small endpoint vulnerable to memory exhaustion attacks. The per-location nginx configuration requires ongoing maintenance: every new endpoint type needs a size limit. Use a conservative default (8KB) and require an explicit override for any endpoint that accepts larger payloads, with the override documented in the OpenAPI spec’s x- extensions.
Gateway enforcement requires the gateway to be on the critical path. If any path to the backend service bypasses the gateway — a Kubernetes service accessed directly by another pod, an internal load balancer route added for debugging, a development mode that disables the proxy — schema validation is not applied on that path. The application-layer Pydantic validation catches this, but only if it is present on every handler. Audit ingress routes regularly and ensure no service is directly reachable from outside the mesh without going through gateway validation.
Failure Modes
Schema documents but does not enforce. The OpenAPI spec declares additionalProperties: false, but the gateway plugin is configured with the schema manually, separately from the spec, and that manual copy was never updated to include the constraint. The spec is documentation; the gateway schema is enforcement. They diverge. The CI check in section 6 addresses this only if the gateway configuration is also linted against the spec — add a step that generates the Kong plugin configuration from the spec directly using openapi-to-kong or equivalent.
Validation only on documented endpoints. The gateway enforces schema validation on every path listed in the OpenAPI spec. A service adds a new endpoint — /api/users/{id}/grant-access — that is deployed but not yet added to the spec. The CI undocumented-routes check catches this if it runs against the deployed service, but if the check only reads from the spec file and not from the running application, the gap persists until someone updates the spec. Fail closed: default deny unknown paths at the gateway.
additionalProperties: false missing from nested object schemas. The top-level schema has the constraint, but nested objects do not. An attacker cannot add role at the root level, but can add preferences.internal_flag or address.user_type if those nested schemas are permissive. The recursive schema check in the CI script addresses this.
Application-layer extra="forbid" not applied uniformly. A developer adds a new endpoint and uses a different Pydantic model — one without extra="forbid" — because they copied a template from an older part of the codebase. Add a base model class that all request models inherit from, with extra="forbid" declared in the base:
class SecureRequestModel(BaseModel):
"""Base class for all API request bodies. Never instantiate directly."""
model_config = ConfigDict(
extra="forbid",
strict=True,
str_max_length=4096,
)
class UserUpdateRequest(SecureRequestModel):
name: str | None = None
email: EmailStr | None = None
# extra="forbid" inherited — no per-model configuration needed
A linting rule enforcing that all *Request classes inherit from SecureRequestModel — added to your ruff or pylint configuration — closes the inheritance gap automatically.
Error responses that confirm field existence. When a validation error mentions the field name in the response body — "field 'role' is not permitted" — the attacker learns that role is a field the model knows about, which confirms the attack target. Return only generic messages to clients. Log field-level detail internally. The Pydantic exception handler in section 4 demonstrates this separation.