Skip to content

Nested mutations allow operations on records outside parent relationship scope #2744

@spawnia

Description

@spawnia

Summary

Nested mutation handlers perform database queries without verifying that related records belong to the intended parent. This allows users to update, delete, connect, or sync records that belong to other parents.

Supersedes: #1400 (which reported only the delete case for HasMany)

Scope of Impact

Handler update delete connect disconnect upsert sync
NestedOneToMany ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ -
NestedOneToOne ⚠️ ⚠️ - - ⚠️ -
NestedManyToMany ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️
NestedBelongsTo ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ -
NestedMorphTo - ⚠️ ⚠️ ⚠️ - -

⚠️ = Unscoped operation that can affect records outside the parent relationship

Root Cause

Nested handlers use unscoped queries when resolving IDs:

// NestedOneToMany.php:77-79 - delete
$relation->getRelated()::destroy($ids);  // Deletes ANY record with these IDs

// UpdateModel.php:33 - update
$model = $model->newQuery()->findOrFail($id->value);  // Finds ANY record

// UpsertModel.php:29-30 - upsert
$existingModel = $model->newQuery()->find($id);  // Finds ANY record

// NestedOneToMany.php:52-58 - connect
->whereIn($relation->make()->getKeyName(), $ids)->get();  // Gets ANY records

Exploitation Example

# User B can update User A's task
mutation {
  updateUser(input: {
    id: 2  # User B
    tasks: {
      update: [{
        id: 1  # Task belonging to User A!
        name: "hacked"
      }]
    }
  }) {
    id
  }
}

Current Documentation

The nested mutations documentation warns:

Lighthouse has no mechanism for fine-grained permissions of nested mutation operations. Field directives such as the @can* family of directives apply to the whole field.

Make sure that fields with nested mutations are only available to users who are allowed to execute all reachable nested mutations.

While this warning exists, many users expect that operations on nested relations would be scoped to the parent automatically.

Proposed Solution

Scope all ID-based operations through the relation:

// Instead of:
$relation->getRelated()::destroy($ids);

// Use:
$relation->whereIn($relation->getRelated()->getKeyName(), $ids)->get()
    ->each->delete();

// Instead of (in UpdateModel/UpsertModel):
$model->newQuery()->findOrFail($id);

// Pass the relation and use:
$relation->findOrFail($id);
// Or for nested contexts, verify the FK matches:
$model->newQuery()
    ->where($foreignKey, $parentId)
    ->findOrFail($id);

Breaking Change Consideration

This would be a breaking change for users who intentionally rely on the current behavior (rare but possible). Options:

  1. Major version bump - Fix in v7
  2. Opt-in flag - Add config option lighthouse.nested_mutations.scope_to_parent
  3. New directives - Add @scopedNested directive, keep current as default

Affected Files

  • src/Execution/Arguments/NestedOneToMany.php
  • src/Execution/Arguments/NestedOneToOne.php
  • src/Execution/Arguments/NestedManyToMany.php
  • src/Execution/Arguments/NestedBelongsTo.php
  • src/Execution/Arguments/NestedMorphTo.php
  • src/Execution/Arguments/UpdateModel.php
  • src/Execution/Arguments/UpsertModel.php

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugAn error within Lighthouse

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions