Skip to content

CWE-501: Trust Boundary Violation

Overview

MITRE defines CWE-501 as an application that "mixes trusted and untrusted data in the same data structure or structured message". In practice that means untrusted data - a request parameter, a header, an uploaded field - being written into a store the rest of the application trusts by definition: the session, a shared cache, an internal context object. Once it is there, nothing distinguishes it from a value the server computed, so every later reader is entitled to trust it. The result is session poisoning, privilege escalation, and security checks that run correctly on an answer the attacker supplied.

MITRE's own example is a servlet that reads a username parameter and puts it in the session before authentication has succeeded, which is the shape in miniature: the write is not itself a decision, and it makes every later decision wrong.

Relationship to Other CWEs

  • CWE-501 (this page) - untrusted data written into a structure the application treats as trusted. MITRE marks it ALLOWED for direct mapping and places it under CWE-664 in the Research Concepts view.
  • CWE-20 (Improper Input Validation) - the finding when the problem is that the value was never validated at all. CWE-501 is narrower: the value may even have been validated for its original purpose, and the defect is that it changed trust level on the way into the store.
  • CWE-642 (External Control of Critical State Data) - the mirror image, and easy to confuse. There the security-critical state is kept somewhere the client controls; here it is kept somewhere the server controls, and untrusted data is what got written into it.
  • CWE-454 (External Initialization of Trusted Variables or Data Stores) - the same shape at startup rather than per request - a trusted variable initialized from the environment or a config file.
  • CWE-349 (Acceptance of Extraneous Untrusted Data With Trusted Data) - MITRE lists this as a peer; it covers the case where trusted and untrusted data arrive together and the untrusted part is accepted along with the rest. No page here.

Exposure in the other direction - trusted data reaching a context that should not hold it - is not this entry. That is CWE-668 and its children, notably CWE-200.

OWASP Classification

A06:2025 - Insecure Design

Risk

High: Attacker-controlled data in a session or a shared cache can set a stored role, poison an entry other users read, or become the value a later security check reads back.

Remediation Steps

Core Principle: Validate untrusted data at the point it crosses into a trusted store, and never take a security-critical value from the request.

Locate Trust Boundary Violations

Working from a scan result:

  • Start at the reported line and identify the write: which untrusted value lands in which trusted store
  • Name the store it lands in: session storage, a cache, an internal object, a security context
  • Check whether anything validates the value before it is stored
  • Find the readers that treat session or cache data as safe
  • Work out what the attacker gains: a session variable they choose, a role they assign themselves, or the answer a later security check reads back

Common trust boundaries:

  • HTTP request -> Session storage
  • User input -> Application cache
  • External data -> Internal security context
  • Untrusted parameters -> Authorization decisions

Validate Before Crossing a Trust Boundary (Primary Defense)

// VULNERABLE - untrusted input stored directly in a trusted context
function set_preference(request):
    pref = request.param('pref')
    session.user_pref = pref   // any value at all now lives in the session, indistinguishable from server-set state

// SECURE - validate against an allowlist before it enters the trusted context
ALLOWED_PREFS = ['light', 'dark', 'auto']

function set_preference(request):
    pref = request.param('pref')
    if pref not in ALLOWED_PREFS:
        return 400
    session.user_pref = pref   // trusted only after validation

Why this works: The check runs at the crossing, so the only values that reach the session are ones on the list. An allowlist states what is acceptable; a blocklist has to anticipate every value that is not.

Never Store Security-Critical Data From the User

// VULNERABLE - the client tells the server what role it should have
role = request.param('role')
session.role = role   // privilege escalation - the attacker picks their own role

// SECURE - security-critical data always comes from an authoritative source
user = user_service.get_authenticated_user()
session.role = user.role   // looked up server-side, never accepted from the request

Why this works: The role comes from a lookup keyed by the authenticated identity, so the request has no say in it. General-purpose data such as display preferences can still be user-supplied once validated; a value a security decision reads cannot.

Separate Trusted and Untrusted Data, and Re-Derive on Read

Keep security-critical fields - user ID, role, authentication timestamp - in storage written only from trusted sources, and put user-supplied data such as preferences and display settings in a separate container validated on write. Having been validated once does not make a stored value safe to read back for a security decision: session storage can be tampered with through fixation, a deserialization bug or a shared-storage misconfiguration, and it can simply hold stale data. Re-derive the value instead.

Test the Fix

  • Attempt to set a privileged role or permission through a request parameter and verify it's rejected or ignored, not written to the session
  • Submit values the schema does not describe - an unexpected type, an extra field, a value outside a declared range - to anything that gets cached or stored, and verify they are refused before reaching trusted storage. Note what this test is not: a <script> payload inside a field the schema declares as a string is a legitimate value here and will be stored. That is correct, and the defence for it is output encoding at the point of use (CWE-79) - asserting it is rejected on the way in would fail a correct implementation of this page's fix
  • Verify security decisions re-check authoritative data rather than trusting session/cache values at face value
  • For a cache, test the key as well as the value. Send a request whose parameters name another user's entry and confirm the write lands under the caller's own key, then read that other user's entry back as its owner and confirm it is unchanged. A schema check on the body passes this test while the cross-user write is still open.
  • Complete one legitimate request of each kind after the change and assert the stored value is present and correct - a schema that rejects everything passes every test above
  • Re-scan with the security scanner to confirm the finding is resolved

Common Vulnerable Patterns

// VULNERABLE - pseudo-code
session.redirect_url = request.param('next')   // open redirect - unvalidated
session.role = request.param('role')            // Attack: ?role=admin makes the attacker an admin

// cache poisoning - untrusted data stored verbatim under a key any caller can
// name, so one request decides what every later reader of that key receives
cache.set('profile:' + request.param('id'), request.json_body)

Why this is vulnerable: the data does not change; its context does. request.param is a value everyone treats as untrusted, and session is a store that the rest of the application trusts by definition - so the assignment launders it. From the next line onward there is no marker distinguishing a session field the server computed from one the client supplied, and every later reader is entitled to assume the former.

The role line shows why this is worse than an ordinary validation gap. The attacker is not defeating the authorization check; they are writing the answer into the location the check reads from, so the check runs correctly and returns what it was given. That also means the escalation persists for the life of the session rather than the request, and it survives any number of subsequent correct authorization decisions.

The cache line has the same shape with a wider blast radius, and two independent defects. The value is stored verbatim, so whatever the caller sent is what later readers get; and the key is built from a request parameter, so the caller also chooses whose entry they are writing. Either one alone is a finding - untrusted content under a legitimately-scoped key still poisons that one entry - but together they let one request decide what every other user's profile lookup returns. Validation has to happen at the moment of crossing, because after the write there is nothing left to indicate that a boundary was crossed at all.

Secure Patterns

// SECURE - pseudo-code
ALLOWED_REDIRECTS = ['/home', '/dashboard']
next = request.param('next')
session.redirect_url = next if next in ALLOWED_REDIRECTS else '/home'

user = user_service.get_authenticated_user()
session.role = user.role   // from the database, never from the request

// parse the body into a declared shape - an unknown field or a wrong type is a
// rejected request, not a stored value
profile = parse_into(ProfileSchema, request.json_body)   // raises on anything the schema does not describe

// key the entry from the authenticated identity, never from a request parameter
cache.set('profile:' + session.user_id, profile)

Why this works: None of these values reaches the trusted store as the client sent it. The redirect target passes through an allowlist, the role comes from a server-side lookup keyed by the authenticated identity, and the profile body is parsed into a declared shape before it is cached.

The cache write needs both halves. Parse, do not sanitize: a parse against a declared schema either produces a value of the shape the application expects or fails, whereas sanitizing a free-form document is an open-ended promise about what every later reader will do with the result. Derive the key from the session, not the request: even a fully validated value is still a cross-user write if the caller chose the key. A schema that admits only the declared fields stops the entry carrying something a later reader trusts; the key scoping stops it being someone else's entry.

Whether the cached value is safe to render is a separate question with a separate answer - output encoding at the point of use, which is CWE-79. Validation on the way in and encoding on the way out are both needed; neither substitutes for the other.

Common Pitfalls

  • Validating on read instead of on write. Checking the session value at each use looks equivalent and is not: every code path has to remember, and the untrusted value is still sitting in the trusted store for the one that forgets. Validate at the crossing, where there is a single place to get it right.
  • A value that was validated for a different purpose. A next parameter checked to be a well-formed URL has been validated, but not against the question the session read will ask - "is this somewhere we are willing to send a user?". Passing an earlier check does not confer trust for a later one.
  • Sanitizing free-form data on the way into a store. An open-ended clean-up has to anticipate what every later reader will do with the result, and it quietly damages legitimate content in the meantime. Parse into a declared shape instead, so the store holds only values the application can describe.
  • Assuming the store is trustworthy because it is server-side. Session backends get shared between environments, cache keys collide, and a deserialization or fixation bug puts attacker-chosen state into a session the server really did write. Re-derive anything security-critical from the authenticated identity rather than reading it back.
  • Fixing the value and leaving the key. For anything keyed by request data - a cache, a rate-limit bucket, a per-tenant store - validating the payload leaves the caller still choosing whose entry they write. Both halves are part of the crossing.

Additional Resources