User Controlled Primary Key
ID |
swift.user_controlled_primary_key |
Severity |
high |
Remediation Complexity |
medium |
Remediation Risk |
medium |
Remediation Effort |
medium |
Resource |
Misconfiguration |
Language |
Swift |
Tags |
ASVS50:v8.2.1, CWE:566, NIST.SP.800-53, PCI-DSS:6.5.6 |
Description
This vulnerability arises when an application uses user-supplied input to control primary key values for database queries or operations, which may lead to unauthorized data access or modification.
Rationale
When user input is directly used to build database queries, including primary keys, it can result in unauthorized access or manipulation of protected data.
This problem is particularly dangerous when primary keys are integers or predictable values, as it enables users to perform unintended actions like accessing records that belong to other users, potentially exposing or corrupting sensitive information.
For example, consider this Vapor handler:
func show(req: Request) async throws -> Invoice {
let invoiceID = req.parameters.get("invoiceID", as: UUID.self)
return try await Invoice.find(invoiceID, on: req.db)! // FLAW
}
The identifier comes from the route, so any caller can read any invoice by changing it. The detector
reports Fluent’s lookups by identifier, Model.find(_:on:) and a query filtered on the model’s
identifier, Invoice.query(on: req.db).filter(\.$id == invoiceID).
It does not report the lookup when the handler already settles whose record it is:
-
the fetched record’s owner is compared against the authenticated caller,
guard invoice.$customer.id == caller.id else { throw Abort(.notFound) }; -
the query is bounded to the caller before anything is read,
.filter(\.$id == invoiceID).filter(\.$customer.$id == caller.id); -
the record goes through a check the application’s convention profile names as an ownership check.
-
the query is bounded to an owner read from a request header named after the owner field (
X-Customer-IDforcustomer), in a handler registered behind a middleware the convention profile names as the source of the caller’s identity:app.grouped(CustomerHeader()).get(…, use: invoices.show).
With a convention profile that names an entity’s owner field and the type the caller is carried in,
a lookup of the application’s own that returns that entity — InvoiceStore.load(id) — is reported
too.
Remediation
To mitigate the risks of CWE-566, it’s essential to avoid constructing database queries using user-supplied input directly or allow user input to control database primary key values. Best practices include:
If the SQL query is constructed without relying on user-controlled input, such as through the use of parameterized SQL, attackers cannot access unauthorized records.
When the identifier legitimately comes from the request, authorize it against the caller: check that the record belongs to the authenticated principal, and prefer doing so before the lookup, so an unauthorized identifier never reaches the database.
Whether a given method of your own performs that authorization is project knowledge, not something recoverable from the code, so the rule cannot infer it. If you delegate the check to a helper, declare that helper as a neutralizer in a custom library metadata file (conf.custom/libraries/<technology>/*.yml), naming the argument that carries the identifier and the primary_controlled_key kind:
# schema: library_schema.json
name: acme-authorization
technology: java
modules:
- name: acme
classes:
- name: com.acme.security.AccessGuard
functions:
- name: validateUser
neutralizations:
- argPos: [0]
kind: primary_controlled_key
Identifiers validated through the declared helper are then no longer reported. An inline check that is not declared this way stays reported, because nothing in the code distinguishes it from an unrelated comparison.
Some mechanisms do not check a value: they scope where the lookup runs. A wrapper runs a callback for one caller or tenant, an annotation or a guard protects a whole handler, or a call installs the scope for the statements after it. Declare such a mechanism with scopes, naming the forms it takes: wrapper, annotation or install. A lookup that runs inside the region is then not reported:
# schema: library_schema.json
name: acme-tenancy
technology: java
modules:
- name: acme
classes:
- name: com.acme.tenancy.TenantContext
functions:
- name: runAs
neutralizations:
- kind: primary_controlled_key
scopes: [ wrapper, install ]
When the application profile names an enforcement point that keeps one caller’s records away from another’s, the same regions are read from it. The forms depend on the kind of enforcement point:
-
An annotation or an interceptor (for example
@UseGuards(OwnerGuard)in NestJS) scopes the handler it applies to, or every handler of the type it decorates. -
A handler call scopes the callback it wraps, and the statements after it in the same function.
A lookup withdrawn this way is kept as an info record with suppressed_by: scoped_context, naming the profile fact and where it was asserted.
| Language | Wrapper (callback or block) | Annotation | Install |
|---|---|---|---|
Java |
lambda, try-with-resources |
method, class |
yes |
Python |
|
decorator on function or class |
yes |
C# |
|
attribute on method or type |
yes, including |
JavaScript / TypeScript |
arrow function, function expression |
decorator, or a class named in its arguments |
yes |
Limits of this version:
-
A mechanism installed in another file (a servlet filter or a middleware setting the tenant for every request) does not scope a region the analysis can see.
-
A method reference, or a named function, handed to a wrapper is not a region: its body does not say where it runs.
-
An install inside a condition, a loop or a
trycovers its own block only. A function defined after an install and run later is not covered. -
A function handed to an executor from inside a region, and run after the region ends, is still considered covered.
-
Kotlin, Go, PHP and Swift have no scoped regions yet. Ruby has no SAST analysis.
In Vapor, compare the owner of the fetched model against the authenticated user, or bound the query to the caller:
func show(req: Request) async throws -> Invoice {
let caller = try req.auth.require(Customer.self)
let invoiceID = req.parameters.get("invoiceID", as: UUID.self)
guard let invoice = try await Invoice.query(on: req.db)
.filter(\.$id == invoiceID)
.filter(\.$customer.$id == caller.requireID())
.first() else {
throw Abort(.notFound)
}
return invoice
}
Configuration
The detector has the following configurable parameters:
-
sources, that indicates the source kinds to check. -
neutralizations, that indicates the neutralization kinds to check.
Unless you need to change the default behavior, you typically do not need to configure this detector.
References
-
CWE-566 : Authorization Bypass Through User-Controlled SQL Primary Key.