VAITP Dataset

← Back to the dataset

CVE-2026-55664

Grist /forms endpoint access control bypass reveals hidden metadata.

  • CVSS 4.3
  • CWE-200
  • Authentication, Authorization, and Session Management
  • Remote

Grist is spreadsheet software using Python as its formula language. Prior to 1.7.15, the GET /forms endpoint read table and column metadata without applying the document's access rules and did not check that the requested section was actually a form. A user with only partial read access, including public access on a publicly viewable document, could request the metadata of any widget and reveal table and column structure that access rules would otherwise hide, even in documents that contain no forms. This issue is fixed in version 1.7.15.

CVSS base score
4.3
Published
2026-07-10
OWASP
A01 Broken Access Control
Orthogonal defect classification
Checking
Code defect classification
Missing Check
Category
Authentication, Authorization, and Session Management
Subcategory
Insecure Direct Object References (IDOR)
Accessibility scope
Remote
Impact
Information Disclosure
Affected component
Grist
Fixed by upgrading
Yes

Solution

Upgrade Grist to version 1.7.15 or later.

Vulnerable code sample

import flask
from flask import request, jsonify

# This is a minimal Flask application to demonstrate the logic of the vulnerability
# described in CVE-2026-55664. It is not the actual Grist source code but
# represents the flawed logic.

app = flask.Flask(__name__)

# --- Mock Database and Data Structures ---
# This simulates a Grist document with tables, columns, and simplified access rules.
MOCK_DOCUMENT_DB = {
    "doc_alpha": {
        "widgets": {
            # This widget is a table that should be private
            "widget_sensitive_employees": {
                "type": "table",
                "tableId": "table_employees"
            },
            # This widget is a table that is public
            "widget_public_products": {
                "type": "table",
                "tableId": "table_products"
            }
        },
        "tables": {
            "table_employees": {
                "columns": [
                    {"id": "col_name", "label": "Name"},
                    {"id": "col_ssn", "label": "SSN"},
                    {"id": "col_salary", "label": "Salary"}
                ]
            },
            "table_products": {
                "columns": [
                    {"id": "col_prod_name", "label": "Product"},
                    {"id": "col_price", "label": "Price"}
                ]
            }
        },
        # Simplified access rules: 'user_public' can only read 'table_products'.
        # An admin or owner would have full access (not shown for simplicity).
        "access_rules": {
            "user_public": {
                "permissions": ["table_products"]
            }
        }
    }
}

# --- Vulnerable Endpoint ---
# This endpoint is intended to provide metadata for "forms", but has two flaws:
# 1. It doesn't check if the requested widget is actually a form.
# 2. It bypasses access control checks, directly accessing table metadata.

@app.route('/forms', methods=['GET'])
def get_forms():
    """
    Vulnerable endpoint that reveals schema of any table, regardless of access rules.
    
    To exploit, a user with partial access (e.g., public) would request the
    metadata for a widget they are not supposed to see.

    Example of legitimate use (by a public user):
    GET /forms?docId=doc_alpha&sectionId=widget_public_products

    Example of exploit (by the same public user):
    GET /forms?docId=doc_alpha&sectionId=widget_sensitive_employees
    """
    doc_id = request.args.get('docId')
    section_id = request.args.get('sectionId') # In reality, this is a widget ID

    if not doc_id or not section_id:
        return jsonify({"error": "docId and sectionId are required"}), 400

    doc = MOCK_DOCUMENT_DB.get(doc_id)
    if not doc:
        return jsonify({"error": "Document not found"}), 404

    widget = doc["widgets"].get(section_id)
    if not widget:
        return jsonify({"error": "Section or widget not found"}), 404

    # --- THE VULNERABILITY ---
    # The code proceeds without checking if the user has rights to view this section.
    # It also doesn't verify that widget['type'] is 'form'.
    # It directly looks up the table metadata using the ID from the widget.
    
    table_id = widget.get('tableId')
    if not table_id:
        return jsonify({"error": "Widget is not associated with a table"}), 400
        
    table_metadata = doc["tables"].get(table_id)

    if not table_metadata:
        return jsonify({"error": "Associated table not found"}), 404

    # The sensitive metadata (column structure) is returned to the user,
    # bypassing any access rules defined for the document.
    return jsonify({
        "tableId": table_id,
        "columns": table_metadata["columns"]
    })

if __name__ == '__main__':
    # To run this example:
    # 1. pip install Flask
    # 2. python your_script_name.py
    # 3. In another terminal, use curl to test the vulnerability:
    #    # This call is allowed for a public user
    #    curl "http://127.0.0.1:5000/forms?docId=doc_alpha&sectionId=widget_public_products"
    #    # This call is NOT allowed, but the vulnerability reveals the sensitive schema
    #    curl "http://127.0.0.1:5000/forms?docId=doc_alpha&sectionId=widget_sensitive_employees"
    app.run(debug=True)

Patched code sample

import collections

# Mock objects to represent Grist's data model and access control system.
# These are simplified for the purpose of demonstrating the fix.
User = collections.namedtuple('User', ['user_id', 'access_level'])
Table = collections.namedtuple('Table', ['id', 'name', 'columns'])
Section = collections.namedtuple('Section', ['id', 'type', 'table_ref'])
Doc = collections.namedtuple('Doc', ['id', 'sections', 'tables', 'access_rules'])

class AccessControl:
    """A mock class to represent checking access rules."""
    def __init__(self, user, doc):
        self._user = user
        self._rules = doc.access_rules

    def can_read_table(self, table_id):
        """Checks if the user has permission to read the specified table."""
        # In a real system, this would evaluate complex rules.
        # For this example, we assume rules explicitly grant or deny access.
        if table_id in self._rules.get('read_permissions', {}).get(self._user.access_level, []):
            return True
        return False

# --- Vulnerable Code (Conceptual) ---
#
# def get_form_metadata_vulnerable(user, doc, section_id):
#     """
#     The vulnerable version would fetch any section and expose its metadata
#     without checking access rules or if the section is a form.
#     """
#     section = next((s for s in doc.sections if s.id == section_id), None)
#     if not section:
#         raise ValueError("Section not found")
#
#     # VULNERABILITY 1: No check if section.type is 'form'.
#     # VULNERABILITY 2: No check if user can read section.table_ref.
#
#     table = next((t for t in doc.tables if t.id == section.table_ref), None)
#     return {'table_id': table.id, 'columns': [c.name for c in table.columns]}
#

# --- Fixed Code ---

def get_form_metadata_fixed(user: User, doc: Doc, section_id: int):
    """
    Represents the fixed GET /forms endpoint handler.

    This version applies two critical checks before returning metadata:
    1. It verifies the requested section is actually a 'form'.
    2. It enforces the document's access rules to ensure the user has
       permission to view the underlying table's metadata.
    """
    section = next((s for s in doc.sections if s.id == section_id), None)

    if not section:
        # Standard not-found response.
        raise ValueError("Resource not found")

    # FIX 1: Ensure the requested section is a 'form' view.
    # This prevents users from targeting other widget types (e.g., 'chart', 'detail')
    # through this endpoint to leak their structure.
    if section.type != 'form':
        raise PermissionError("Requested resource is not a form.")

    acl = AccessControl(user, doc)
    table_id_to_check = section.table_ref

    # FIX 2: Apply the document's access rules.
    # Check if the user has explicit read access to the table backing the form.
    # This prevents a user with partial access from viewing the structure of
    # tables they are not supposed to see.
    if not acl.can_read_table(table_id_to_check):
        raise PermissionError("Access denied to view underlying table metadata.")

    # If all checks pass, return the metadata for the form's table.
    table = next((t for t in doc.tables if t.id == table_id_to_check), None)
    if not table:
         raise ValueError("Underlying table not found") # Should not happen in consistent data

    return {'table_id': table.id, 'columns': [c.name for c in table.columns]}

Cite this entry

@misc{vaitp:cve202655664,
  title        = {{Grist /forms endpoint access control bypass reveals hidden metadata.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2026},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2026-55664},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-55664/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.

The supreme art of war is to subdue the enemy without fighting.

Sun Tzu – “The Art of War”

:: Shaping the future through research and ingenuity ::