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

orderRepository.deleteAll(), deleteAllInBatch()

A filter that filters nothing

couponMapper.delete(null), update(entity, new UpdateWrapper<>()), collection.deleteMany(new Document()), a MyBatis Generator deleteByExample(example) whose Example never got a criterion

SQL or JPQL without WHERE

jdbc.update("DELETE FROM orders"), TRUNCATE TABLE coupons, a repository method whose @Query has no WHERE

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

db.Orders.ExecuteDelete(), ExecuteDeleteAsync(), db.Orders.ExecuteUpdate(s ⇒ s.SetProperty(…​)), and linq2db’s db.Orders.TruncateAsync() on an ITable<Order>

A whole set passed to a range write

db.Orders.RemoveRange(db.Orders), RemoveRange(db.Orders.ToList()), or a local holding db.Orders.ToList() the method uses nowhere else

SQL without WHERE

db.Database.ExecuteSqlRaw("DELETE FROM Orders"), ExecuteSqlInterpolated($"…​"), Dapper’s connection.Execute("DELETE FROM Orders"), an ADO.NET new SqlCommand("DELETE FROM Orders", conn).ExecuteNonQuery() or cmd.CommandText = "…​"

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 prisma.order.deleteMany(), deleteMany({}), updateMany({ data }); Sequelize Order.destroy({ where: {} }), destroy({ truncate: true }), Order.update(values, { where: {} })

A filter that filters nothing

Mongoose Order.deleteMany({}), updateMany({}, …); TypeORM repository.clear(), repository.delete({}), manager.clear(Order)

A query builder whose chain never filters (no where, whereRef, having…)

knex knex('orders').del(), Kysely db.deleteFrom('orders').execute(), db.updateTable('orders').set(…).execute(), Drizzle db.delete(orders), TypeORM createQueryBuilder().delete().from(Order).execute()

SQL without WHERE

+prisma.$executeRaw`DELETE FROM orders+, `$executeRawUnsafe(…), sequelize.query(…), pool.query(…), knex.raw(…), Drizzle +db.execute(sql…)+

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 orderRepository.deleteAll(), deleteAllInBatch(); Exposed Orders.deleteAll(), Orders.update { it[status] = "X" } with no where-lambda

A query builder whose chain never filters

jOOQ dsl.deleteFrom(ORDERS).execute(), dsl.update(ORDERS).set(ORDERS.STATUS, x).execute()

SQL or JPQL without WHERE

jdbcTemplate.update("DELETE FROM orders"), entityManager.createQuery("DELETE FROM Order o"), Exposed exec("TRUNCATE TABLE orders"), a repository function whose @Query has no WHERE

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

db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&Order{}), db.Session(&gorm.Session{AllowGlobalUpdate: true}).Model(&Order{}).Update("status", "X"); with the v1 import path, db.Delete(&Order{})

An ent bulk builder run with no Where

client.Order.Delete().Exec(ctx), client.Order.Update().SetStatus("X").Save(ctx)

SQL without WHERE

db.ExecContext(ctx, "DELETE FROM orders") with database/sql, db.MustExec("DELETE FROM orders") with sqlx, GORM’s db.Exec("TRUNCATE TABLE orders"), a package constant holding the statement

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

Order::query()→delete(), Order::truncate(), DB::table('orders')→delete(), DB::table('orders')→update(['status' ⇒ 'X'])

A Doctrine QueryBuilder write with no where()

$em→createQueryBuilder()→delete(Order::class, 'o')→getQuery()→execute(), $em→getRepository(Order::class)→createQueryBuilder('o')→delete()→getQuery()→execute()

SQL or DQL without WHERE

$pdo→exec('DELETE FROM orders'), DB::statement('TRUNCATE TABLE orders'), $em→createQuery('DELETE FROM App\Entity\Order o')→execute()

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

Wallet.delete_all, Wallet.all.update_all(label: 'X'), Wallet.destroy_all, Wallet.touch_all, and the same through a local (scope = Wallet.all then scope.delete_all)

A Sequel dataset write whose chain never filters

DB[:wallets].delete, DB.from(:wallets).update(label: 'X')

SQL without WHERE

ActiveRecord::Base.connection.execute('DELETE FROM wallets'), DB.run('TRUNCATE TABLE wallets')

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

Ticket.query(on: req.db).delete(), Ticket.query(on: req.db).set(\.$title, to: "").update(), and the same through a local (let query = Ticket.query(on: db) then query.delete())

An SQLKit builder write with no where

sql.delete(from: "tickets").run(), sql.update("tickets").set("title", to: "").run()

SQL without WHERE

sql.raw("DELETE FROM tickets").run()

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), a WHERE 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 in baseRoles (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.

Configuration

The calls, the empty filters, the query-builder filters and the audit fields are listed per language in the detector’s properties (bulkOperations, emptyPredicates, chainFilters, auditFields), and the base roles in baseRoles. A new entry needs no code.

© 2026 Xygeni. All rights reserved