Skip to content

Console crashes when rendering database object names containing regex metacharacters #10840

@fandu-provally

Description

@fandu-provally

Version Information

Server Version: v2.48.16
CLI Version (for CLI related issue): N/A

Environment

Hasura GraphQL Engine V2 Console
Provider: Docker / Hasura Cloud / local dev setup
Database: PostgreSQL
Browser: Chrome / Chromium

What is the current behaviour?

The Hasura Console can crash and show the global error page when database object names containing regular expression metacharacters are rendered in the Data sidebar/tree.

I observed this with an object name containing an unbalanced (. The Console displayed:

SyntaxError: Invalid regular expression: /\/data\/default\/schema\/public\/(tables|functions|views)\/.../: Unterminated group

This prevents normal use of parts of the Data Console until the problematic route/object is avoided.

The likely cause is that database/source/schema/object names are interpolated directly into RegExp patterns without escaping:

  • frontend/libs/console/legacy-ce/src/lib/components/Services/Data/TreeView.tsx
    const regex = new RegExp(
      `\\/data\\/${currentSource}\\/schema\\/${currentSchema}\\/(tables|functions|views)\\/${item.name}\\/`
    );
    

What is the expected behaviour?

The Console should treat database object names and search terms as literal strings. Regex metacharacters such as (, [, +, ?, *, |, , etc. should not crash rendering or alter matching semantics.

How to reproduce the issue?

Run Hasura GraphQL Engine V2 with a PostgreSQL source.
Create or track a table/function/view whose name contains a regex metacharacter, for example an unbalanced (.
Open the Hasura Console and navigate to the Data tab / affected schema or object.
The Console renders the Data tree and throws a SyntaxError: Invalid regular expression.
The app falls back to the global Error page, making parts of the Console unusable.

OR
Execute following code with ./poc/hasura_console_regex_crash_poc.py --url http://localhost:18080

#!/usr/bin/env python3
"""
Hasura Console crash via unescaped RegExp input.

This script prepares a local Hasura V2 instance for reproduction and opens the
crashing Console route automatically. No manual Console interaction is needed.

Tested against the repository Docker manifest image:
  hasura/graphql-engine:v2.48.16
"""

import argparse
import json
import os
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request


DEFAULT_DATABASE_URL = (
    "postgres://postgres:postgrespassword@provally-hasura-postgres:5432/postgres"
)


def parse_args():
    parser = argparse.ArgumentParser(
        description="Trigger the Hasura Console RegExp crash without manual interaction."
    )
    parser.add_argument(
        "--url",
        default="http://localhost:18080",
        help="Base URL of the Hasura GraphQL Engine server.",
    )
    parser.add_argument(
        "--source",
        default="default",
        help="Hasura source name to create/use.",
    )
    parser.add_argument(
        "--schema",
        default="public",
        help="Postgres schema to create/use.",
    )
    parser.add_argument(
        "--table-name",
        default="provally_crash_(",
        help="Quoted Postgres table name containing a regex metacharacter.",
    )
    parser.add_argument(
        "--database-url",
        default=DEFAULT_DATABASE_URL,
        help="Database URL as seen from the Hasura container.",
    )
    parser.add_argument(
        "--browser-app",
        default="",
        help='macOS browser app name, e.g. "Google Chrome". Defaults to system browser.',
    )
    parser.add_argument(
        "--no-open",
        action="store_true",
        help="Prepare the PoC but do not open the browser.",
    )
    parser.add_argument(
        "--wait-seconds",
        type=int,
        default=5,
        help="Seconds to wait after opening the crash URL.",
    )
    return parser.parse_args()


def base_url(value):
    return value.rstrip("/")


def check_tcp(url, timeout=5):
    parsed = urllib.parse.urlparse(url)
    host = parsed.hostname or "localhost"
    port = parsed.port or (443 if parsed.scheme == "https" else 80)
    with socket.create_connection((host, port), timeout=timeout):
        return host, port


def request_json(method, url, payload=None, timeout=30):
    data = None
    headers = {"Content-Type": "application/json"}
    if payload is not None:
        data = json.dumps(payload).encode("utf-8")

    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as response:
            body = response.read().decode("utf-8")
            return response.status, body
    except urllib.error.HTTPError as exc:
        body = exc.read().decode("utf-8", errors="replace")
        return exc.code, body


def wait_for_hasura(url, timeout=60):
    deadline = time.time() + timeout
    health_url = f"{url}/healthz"

    while time.time() < deadline:
        try:
            status, body = request_json("GET", health_url, timeout=5)
            if status == 200 and body.strip() == "OK":
                return
        except Exception:
            pass
        time.sleep(1)

    raise RuntimeError(f"Hasura did not become healthy at {health_url}")


def metadata(url, payload):
    return request_json("POST", f"{url}/v1/metadata", payload)


def query(url, payload):
    return request_json("POST", f"{url}/v2/query", payload)


def is_success_or_known_duplicate(status, body):
    if 200 <= status < 300:
        return True

    duplicate_markers = (
        "already exists",
        "already tracked",
        "source with name",
        "already present",
    )
    lower_body = body.lower()
    return status == 400 and any(marker in lower_body for marker in duplicate_markers)


def ensure_source(url, source, database_url):
    payload = {
        "type": "pg_add_source",
        "args": {
            "name": source,
            "configuration": {
                "connection_info": {
                    "database_url": database_url,
                    "isolation_level": "read-committed",
                    "use_prepared_statements": False,
                }
            },
        },
    }
    status, body = metadata(url, payload)
    if not is_success_or_known_duplicate(status, body):
        raise RuntimeError(f"pg_add_source failed: HTTP {status}: {body}")

    print(f"[+] Source ready: {source}")


def quote_ident(value):
    return '"' + value.replace('"', '""') + '"'


def ensure_bad_table(url, source, schema, table_name):
    sql = (
        f"CREATE SCHEMA IF NOT EXISTS {quote_ident(schema)};"
        f"CREATE TABLE IF NOT EXISTS {quote_ident(schema)}.{quote_ident(table_name)} "
        "(id serial primary key);"
    )
    status, body = query(
        url,
        {
            "type": "run_sql",
            "args": {
                "source": source,
                "sql": sql,
            },
        },
    )
    if not (200 <= status < 300):
        raise RuntimeError(f"run_sql failed: HTTP {status}: {body}")

    status, body = metadata(
        url,
        {
            "type": "pg_track_table",
            "args": {
                "source": source,
                "table": {
                    "schema": schema,
                    "name": table_name,
                },
            },
        },
    )
    if not is_success_or_known_duplicate(status, body):
        raise RuntimeError(f"pg_track_table failed: HTTP {status}: {body}")

    print(f"[+] Problematic table ready: {schema}.{table_name}")


def crash_url(url, source, schema):
    source_path = urllib.parse.quote(source, safe="")
    schema_path = urllib.parse.quote(schema, safe="")
    return f"{url}/console/data/{source_path}/schema/{schema_path}"


def open_browser(url, browser_app):
    if sys.platform == "darwin":
        command = ["open"]
        if browser_app:
            command.extend(["-a", browser_app])
        command.append(url)
        subprocess.run(command, check=True)
        return

    import webbrowser

    if not webbrowser.open(url):
        raise RuntimeError(f"Unable to open browser for {url}")


def main():
    args = parse_args()
    target = base_url(args.url)

    print(f"[*] Target: {target}")
    host, port = check_tcp(target)
    print(f"[+] TCP reachable: {host}:{port}")

    wait_for_hasura(target)
    status, body = request_json("GET", f"{target}/v1/version")
    print(f"[+] Hasura version: {body.strip() if status == 200 else 'unknown'}")

    ensure_source(target, args.source, args.database_url)
    ensure_bad_table(target, args.source, args.schema, args.table_name)

    url_to_open = crash_url(target, args.source, args.schema)
    print()
    print("[*] Crash trigger URL:")
    print(f"    {url_to_open}")
    print()
    print("[*] Expected result:")
    print("    The Hasura Console opens the Data tree and renders the global Error page.")
    print("    The visible error should mention an invalid regular expression and")
    print("    'Unterminated group'.")

    if args.no_open:
        print("[*] --no-open set; browser was not opened.")
        return

    open_browser(url_to_open, args.browser_app)
    time.sleep(max(args.wait_seconds, 0))
    print("[+] Browser opened. Verify the visible Console tab shows the Error page.")


if __name__ == "__main__":
    try:
        main()
    except Exception as exc:
        print(f"[!] {exc}", file=sys.stderr)
        sys.exit(1)

Screenshots or Screencast

Image

Please provide any traces or logs that could help here.

Relevant stack trace from the browser Console:

SyntaxError: Invalid regular expression: //data/default/schema/public/(tables|functions|views)/.../: Unterminated group
at new RegExp ()
...
Relevant code locations:

// TreeView.tsx
new RegExp(
\\/data\\/${currentSource}\\/schema\\/${currentSchema}\\/(tables|functions|views)\\/${item.name}\\/
)
// HighlightText.tsx
text.split(new RegExp((${highlightedText}), 'gi'))

Any possible solutions/workarounds you're aware of?

Escape user/database-controlled strings before interpolating them into a regex, or avoid regex construction entirely for route/string matching.

Example:

const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[]\]/g, '\$&');
Then apply it to currentSource, currentSchema, item.name, and highlightedText before constructing RegExp.

For TreeView.tsx, another option is to compare normalized path segments without RegExp.

Keywords

console, data sidebar, TreeView, HighlightText, RegExp, regex escaping, SyntaxError, database object names, client-side crash

Metadata

Metadata

Assignees

No one assigned

    Labels

    k/bugSomething isn't working

    Type

    No type
    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