Destructive Operation Without Scope
ID |
destructive_operation_without_scope |
Severity |
high |
Remediation Complexity |
medium |
Remediation Risk |
high |
Remediation Effort |
medium |
Family |
API1:2023 - Broken Object Level Authorization, API5:2023 - Broken Function Level Authorization |
CWE |
CWE-639, CWE-285 |
Resource |
source |
Language |
Java, C#, JavaScript/TypeScript, Kotlin, Go, PHP, Ruby, Swift |
Description
An endpoint deletes or updates every row of a table in one call, and the rows of that table belong to someone. One request from any caller who reaches it removes or overwrites the data of every user, every customer or every tenant.
The bulk write alone is not the finding. Replacing a global list, resetting a configuration singleton or clearing a cache table is written this way on purpose. What makes it a finding is that the entity written has an owner: its rows say whose they are.
The detector follows the endpoint’s handler into the services and helpers it calls — through an injected interface into its implementation, into private helpers, into inherited methods — and reads every method it reaches for three shapes:
| Shape | Examples |
|---|---|
A call with no filter at all |
|
A filter that filters nothing |
|
SQL or JPQL without |
|
A filter held in a local variable counts as empty when the method never touches the variable again
before the call. A WHERE 1=1 counts as no WHERE. SQL assembled from literals and constants is read; SQL built at
runtime is not.
An entity has an owner when it, or a class it extends, declares a field naming one (userId,
tenantId, customer, account, member, createdBy, or a field whose type is User), or when
the convention profile states which field that is. Fields that record who last touched a row
(creator, updater, lastModifiedBy) are not ownership.
Security Impact
Any caller who can reach the endpoint can destroy or overwrite data that is not theirs, all of it at once. Unlike an object-level authorization flaw, which exposes one record per request, one call is enough.
Example
@RestController
public class OrderController {
private final OrderService orderService;
@DeleteMapping("/orders")
public void clear() {
orderService.clear(); // reaches the bulk delete below
}
}
public class OrderServiceImpl implements OrderService {
private final OrderRepository orderRepository;
public void clear() {
orderRepository.deleteAll(); // FLAW: every user's orders
}
}
public class Order {
private Long id;
private Long userId; // the rows belong to someone
}
C#
In ASP.NET Core the handler is followed the same way: through a field, a property, a constructor or
primary-constructor parameter typed as an interface (IOrderService) into every project class
implementing it, which is what the dependency-injection container registers.
| Shape | Examples |
|---|---|
An EF Core bulk call on a set nothing filters |
|
A whole set passed to a range write |
|
SQL without |
|
A query is unfiltered when it reaches its DbSet only through calls that narrow nothing
(ToList, AsNoTracking, Include, OrderBy…); a Where scopes it. An entity with a global
query filter (HasQueryFilter in OnModelCreating or an IEntityTypeConfiguration) is scoped for
every EF query unless the query calls IgnoreQueryFilters(); raw SQL is not scoped by it. The
entity of db.Orders is the type argument of its DbSet<Order> property; the entity of SQL is
found from its table. The owner is looked for in the entity’s fields and properties
(public string UserId { get; set; }).
An endpoint restricted to a role or to a named policy is not read: [Authorize(Roles = "Admin")] or
[Authorize(Policy = "AdminOnly")] on the action or the controller, or an action filter that rejects
callers without a role. A named policy is function-level authorization, as a role is. A bare
[Authorize] only requires a signed-in caller, so it does not count and the bulk write is reported.
[ApiController]
public class OrderController : ControllerBase
{
private readonly IOrderService _orders;
[HttpDelete("/orders")]
public IActionResult Clear()
{
_orders.Clear();
return Ok();
}
}
public class OrderService : IOrderService
{
private readonly ShopDbContext _db;
public void Clear()
{
_db.Orders.ExecuteDelete(); // every user's orders
}
}
Not read in C#: a call on an extension method’s receiver or through a chained call
(Factory().Create().Clear()), a base. call, a local function, a set obtained from a generic
repository whose type argument is only known at runtime (Set<T>()), SQL built at runtime or with
string.Format, and a query or command held in a variable the method refines.
JavaScript and TypeScript
The handler is the NestJS controller method, or the function an Express, Koa, Fastify or Hono route
registration passes, or a Next.js route handler — inline, named in the file, or imported, including
a factory whose returned function serves the route. It is followed into the functions it calls: a
method of the same class (this.helper()), a NestJS provider injected through the constructor
(constructor(private readonly orders: OrdersService), the one place the written type is read), an
imported or required function, a module-level instance (export const orderService = new
OrderService()), a method of the object a factory returns (const svc = goalService(db)), and a
module imported whole. Imports are followed when they name a file of the project: relative, through
the paths of tsconfig.json, or into a workspace package.
| Shape | Examples |
|---|---|
Options that carry no filter |
Prisma |
A filter that filters nothing |
Mongoose |
A query builder whose chain never filters (no |
knex |
SQL without |
|
The entity is the Prisma model the client’s accessor names (prisma.order, tx.order,
this.db.prisma.order are the model Order of schema.prisma), the entity of a TypeORM repository
(@InjectRepository(Order), Repository<Order>, getRepository(Order), a class extending
Repository<Order>), the Sequelize or Mongoose model (a class extending Model with its init,
sequelize.define('Order', …), mongoose.model('Order', schema), @InjectModel(Order.name)), the
Drizzle table, or, for a builder or SQL, the model, entity class or definition its table names. Its
owner is looked for among the model’s fields, the entity’s properties, or the schema’s paths.
An endpoint restricted to a role is not read: NestJS @Roles(…) or @Permissions(…) on the
handler or its controller, or a role middleware on an inline route registration
(router.delete('/orders', requireRole('admin'), async (req, res) ⇒ …)). An authentication guard
alone — @UseGuards(JwtAuthGuard), @Authenticated() — does not count: any logged-in caller passes it.
@Controller('orders')
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Delete()
clear() {
return this.orders.clear();
}
}
@Injectable()
export class OrdersService {
constructor(private readonly prisma: PrismaService) {}
clear() {
return this.prisma.order.deleteMany(); // every user's orders
}
}
Not read in JavaScript: a call on an untyped parameter (db handed in without a type, res), on
the result of a call this cannot follow, or on a computed member (services[name]); a provider
bound to an injection token or an interface; a filter held in a variable the function changes or
spreads from elsewhere; a builder kept in a variable to be refined later; SQL built at runtime; and a
Kysely table declared only as a TypeScript interface.
Kotlin
The handler is a Ktor route — the trailing lambda of get(…) { }, delete(…) { } — or a
controller method named by the endpoint. It is followed through a constructor property
(class OrderController(private val service: OrderService)), a lateinit var or other property, a
parameter of the enclosing route function (fun Route.orderRoutes(repo: OrderRepository)), a local
built by a constructor call or injected (val repo: OrderRepository by inject()), an object or a
companion called by its name, and a top-level function; an interface or an abstract class leads to
every project class implementing it. Overloads are matched by name and arity, counting parameters
with a default value as optional and a trailing lambda as an argument.
| Shape | Examples |
|---|---|
A call with no filter at all |
Spring Data |
A query builder whose chain never filters |
jOOQ |
SQL or JPQL without |
|
The entity of a Spring Data repository is the type argument its supertype binds
(interface OrderRepository : JpaRepository<Order, Long>); an Exposed table is an object extending
one of Exposed’s table classes, directly or through the project’s own base; the entity of SQL or of a
jOOQ table (ORDERS) is found from its table name. The owner is looked for in the entity’s
constructor properties and properties (data class Order(val id: Long, val userId: Long)), or in
the table’s columns: val userId = long("user_id"), reference("owner", UserTable).
Orders.deleteWhere { Orders.userId eq userId } and Orders.update({ Orders.id eq id }) { … } are
scoped.
fun Route.orderRoutes(service: OrderService) {
delete("/orders") {
service.clear()
call.respond(HttpStatusCode.NoContent)
}
}
class OrderService {
fun clear() = transaction {
Orders.deleteAll() // every user's orders
}
}
Not read in Kotlin: a call on a lambda’s implicit receiver (Ktor’s call, the receiver of with or
apply) or on the result of another call, an extension function on a library type, a query builder
held in a variable, SQL built with a string template, and a Spring controller written in Kotlin
unless the endpoint discovery names its class and method.
Go
The handler is what the route registration names — an inline function, a function of the package or
of another package of the module, or a method value (h.PurgeOrders) — for net/http, gorilla/mux,
Gin, Echo, Fiber and chi. It is followed through the struct fields, parameters and locals whose type
the code states, and a call on an interface into every type of the module whose methods implement
it.
| Shape | Examples |
|---|---|
A GORM write whose chain never filters, over a model value that names no row |
|
An ent bulk builder run with no |
|
SQL without |
|
GORM v2 refuses a delete or update with no condition unless the chain opts in with
AllowGlobalUpdate, so a chain that does not is not reported — unless it gives GORM a condition that
holds for every row, db.Where("1 = 1").Delete(&Order{}), which filters nothing. A model value that carries its key
(Delete(&order) after reading it, Delete(&Order{ID: id})) writes that row. A struct maps to the
table GORM names it — its TableName(), or its name in snake case and plural — and an ent schema to
its own; the owner is a field of the struct (UserID uint, db:"tenant_id", a field typed User)
or of the schema (field.Int("user_id")), or a unique edge to the owner
(edge.From("owner", User.Type).Unique()).
func (h *Handlers) PurgeOrders(w http.ResponseWriter, r *http.Request) {
h.db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&models.Order{}) // every user's orders
w.WriteHeader(http.StatusNoContent)
}
Go declares no security on a handler, so the role is read where Go code writes it: a role call on
the handler’s path (if !authz.HasRole(r, "admin") { return }), or a middleware on its route —
beside the handler in the registration, on the router it is registered on (admin.Use(RequireRole("admin")),
r.With(RequireRole("admin")), a group given the middleware, r.Use(authz.RequireSuperuser)), or on
the router a chi Route closure is handed. A role check handed a base role
(RequireRole("user")) admits every signed-in caller and restricts nothing.
Not read in Go: a GORM query or an ent builder built in a variable and run later, a receiver
returned by a library call, and SQL built at runtime (fmt.Sprintf).
PHP
The handler is the controller action a Laravel route names ([OrderController::class, 'purge'],
'App\Http\Controllers\OrderController@purge'), the closure a route is given, or the method a
Symfony #[Route] attribute is on. It is followed into the methods of the project it calls: its own
($this→purge()), a collaborator’s through a typed property or parameter ($this→orders→purge(),
a promoted constructor parameter), and a class’s static method.
| Shape | Examples |
|---|---|
An Eloquent or query-builder write whose chain never filters |
|
A Doctrine QueryBuilder write with no |
|
SQL or DQL without |
|
A Laravel model maps the table its $table names, or its name in snake case and plural, and its
columns are the ones the project’s migrations give that table ($table→foreignId('user_id')); a
text column is an owner only when its name is a key’s (user_id), so $table→string('user'), a
login name, is not. A write through a relation of one model ($project→tasks()→update(…) after
Project::create(…) or →first()) is narrowed to the rows keyed by it and is not reported. A
Doctrine entity’s fields are its mapped properties. A table no class maps is read from the migrations
or from the SQL DDL (CREATE TABLE orders (…, user_id BIGINT, …)).
public function purge()
{
Order::query()->delete(); // every user's orders
return response()->noContent();
}
The role is read where a PHP application writes it: Laravel’s role: middleware naming no base role
(role:admin, not role:user), permission: and can: middleware, or any middleware handed only
privileged roles (authorize:superuser), on the route or on a group around it, a Gate check of an ability on the handler’s path
(Gate::authorize('purge-orders'), $this→authorize('purge-orders')), and Symfony’s
#[IsGranted('ROLE_ADMIN')] and $this→denyAccessUnlessGranted('ROLE_ADMIN'). A Gate check handed
a model (Gate::authorize('delete', $order)) is about that row and restricts nothing here.
Not read in PHP: a builder returned by a method and written by its caller, a call on an untyped variable, and SQL built at runtime.
Ruby
The handler is the controller action a Rails route names (delete '/wallets', to: 'wallets#purge',
resources :wallets) or the block of a Sinatra route. It is followed into the methods of the project
it calls: its own and its superclasses' (purge_rows, self.purge_rows), a Sinatra helpers
method, a class’s own method (WalletService.purge) and an instance’s (WalletService.new.purge, or
through a local assigned WalletService.new).
| Shape | Examples |
|---|---|
An ActiveRecord write whose chain never filters |
|
A Sequel dataset write whose chain never filters |
|
SQL without |
|
A chain is scoped when one of its links filters with a condition: where(user_id: current_user.id),
where.not(…), find_by(…), limit(…), on the same line or on the next one. where(nil) and
where({}) filter nothing. An ActiveRecord model maps the table self.table_name names, or its name
in snake case and plural, and its columns are the ones db/schema.rb or the migrations give that
table; belongs_to :user adds user_id. A Sequel dataset and raw SQL name the table themselves, and
its columns are read from the same schema or from a Sequel migration.
def purge_wallets
Wallet.delete_all # every user's wallets
head :no_content
end
The role is read where a Ruby application writes it: a before_action naming a role check that
applies to the action (before_action :require_admin, only: [:purge_wallets]), a role check on the
handler’s path (require_admin!, rolify’s has_role?(:admin)), and Pundit’s authorize or
CanCanCan’s authorize! (authorize Wallet, :purge?, authorize! :purge, Wallet).
Not read in Ruby: a relation returned by a method and written by its caller, a write through an
association (current_user.wallets.delete_all is scoped by it), a call on a receiver that is not a
class of the project, and SQL built at runtime. The text of the code is read with its comments and
the contents of its strings blanked, so a delete_all in a comment or a message is not a call.
Swift
The handler is the method or closure a Vapor route is registered with (app.delete("tickets", use:
tickets.purge), routes.delete("tickets") { req in … }), found in the controller the registration
constructs even when another file declares it. It is followed into the methods of the project it
calls: its own type’s (self.purge()), a collaborator’s through a stored property or a local whose
type is written or constructed (let tickets: TicketService, let tickets = TicketService()), and
a type’s static method.
| Shape | Examples |
|---|---|
A Fluent query write whose chain never filters |
|
An SQLKit builder write with no |
|
SQL without |
|
A chain is scoped when one of its links filters: filter(\.$user.$id == user.requireID()),
group, range, SQLKit’s where and limit. A Fluent model maps the table its static let schema
names, and its columns are the keys of its property wrappers (@Parent(key: "user_id") var user:
User) and the ones the project’s migrations give that table (.field("user_id", .uuid,
.references("users", "id"))). A table no model maps is read from the migrations.
func purgeTickets(req: Request) async throws -> HTTPStatus {
try await Ticket.query(on: req.db).delete() // every user's tickets
return .noContent
}
The role is read where a Vapor application writes it: a guard on the authenticated caller’s role in
the handler (guard user.role == .admin else { throw Abort(.forbidden) }, guard user.isAdmin), and
a role check on the handler’s path (hasRole, requireAdmin). A guard on a flag that names no role
(guard user.isActive) and req.auth.require(User.self) authenticate the caller and restrict
nothing here.
Not read in Swift: a query returned by a method and written by its caller, a write through a
relation (user.$tickets.query(on: db).delete() is scoped by it), a call on a value whose type is not
written, and SQL built at runtime.
Remediation
Scope the write to the caller’s rows:
public void clear(Long currentUserId) {
orderRepository.deleteByUserId(currentUserId);
}
-
Filter the delete or update by the owner: a derived query such as
deleteByUserId, a wrapper with.eq(Order::getUserId, userId), aWHERE owner_id = ?bound to the authenticated caller. -
Where the endpoint really must act on every row, restrict it to an administrative role and record who ran it.
Not Reported
-
An endpoint restricted to a role, declared on the endpoint or checked by the handler (
@PreAuthorize("hasRole('ADMIN')"),@Secured("ROLE_MANAGER"),[Authorize(Roles = "Admin")],hasRole('admin')): an administrator clearing a table is a feature. A restriction that admits a base role — a role every signed-in caller holds, listed inbaseRoles(USER,ROLE_USER,IS_AUTHENTICATED_FULLY…) — does not count: one that names only base roles, or names one among alternatives (hasRole('USER') or hasRole('ADMIN'),hasAnyRole,Roles = "User,Admin",role:user), restricts nobody who can sign in. Nor does an annotation that only authenticates (@PreAuthorize("isAuthenticated()")). Whether the role is the right one is `broken_function_level_authorization’s question. -
A filter or a statement built at runtime, a variable holding a query wrapper or a
Specification: whether it is scoped cannot be seen in the code. -
A bulk write over an entity with no owner field: configuration, catalogues, lookup tables.
-
A bulk write over the users or accounts table itself (
UPDATE users SET remember_token = NULL): every row is its own user’s, and resetting all of them is account administration. Whether only an administrator can reach it is `broken_function_level_authorization’s question. -
A bulk write the handler does not reach: schedulers, seeders, migrations, tests.
-
A call whose receiver cannot be resolved to a project type, such as a chained call.