Unsafe-eval (CSP-related)

HIGH
grafana/grafana
Commit: 0cb10419584a
Affected: < 12.4.0
2026-06-12 15:56 UTC

Description

This commit fixes a CSP/unsafe-eval vulnerability in the DataFrames join materialization path. Previously, joinTabular used runtime code generation via new Function(...), which creates a function at runtime and can trigger CSP violations or be exploited if input data could influence the generated code. The patch removes the unsafe-eval usage by replacing the dynamic codegen path with a column-major, precomputed per-row source plan for materialization. This preserves behavior while eliminating the runtime code generation step that posed a CSP/code execution risk.

Commit Details

Author: Paul Marbach

Date: 2026-06-12 15:36 UTC

Message:

DataFrames: Materialize tabular joins without runtime codegen (unsafe-eval) (#126110) * DataFrames: Add characterization tests for joinTabular edge cases Pin the behavior of multi-frame tabular joins (the iterative merge that re-joins a previously cartesian-expanded left table) and null join-column values, neither of which was covered. These guard a follow-up refactor of the materialization step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * DataFrames: Materialize tabular joins without runtime codegen joinTabular used new Function() to compile a column-unrolled materialize step, which requires the 'unsafe-eval' CSP directive. Replace it with a column-major fill driven by a precomputed per-row source plan: this keeps the hot path free of an inner per-column loop, is more cache-friendly, and needs no eval. Behavior is unchanged (guarded by the join test suites). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Triage Assessment

Vulnerability Type: Unsafe-eval (CSP-related)

Confidence: HIGH

Reasoning:

The commit replaces the use of runtime code generation via new Function() (unsafe-eval) with a precomputed plan to materialize joins, removing a CSP-required unsafe-eval path. This mitigates a potential CSP bypass / code execution risk

Verification Assessment

Vulnerability Type: Unsafe-eval (CSP-related)

Confidence: HIGH

Affected Versions: < 12.4.0

Code Diff

diff --git a/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts index 4a3ddabb4426..7a2363670c8b 100644 --- a/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts +++ b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts @@ -357,106 +357,80 @@ function joinTabular(tables: AlignedData[], outer = false) { // console.timeEnd('unmatched right'); /** - * Now we can use matched, unmatchedLeft, unmatchedRight, ltable, and rtable to assemble the final table - * Instead of using 3-deep nested loops, we eliminate the loops over the known column structure - * For this we compile a new function using the schemas from both tables, and filling that struct by looping - * over the matched lookup array, then appending the unmatched left rows (and null-filling the right values), - * then appending the unmatched right rows (and null-filling the left values). + * Now we can use matched, unmatchedLeft, unmatchedRight, ltable, and rtable to assemble the final table. * - * The assembled function looks something like this when joining 2-col left + 2-col right: + * To keep the hot path free of an inner per-column loop (without resorting to runtime codegen, which + * requires the 'unsafe-eval' CSP directive) we materialize column-major: * - * function anonymous(matched, unmatchedLeft, unmatchedRight, ltable, rtable) { - * const joined = [Array(99991),Array(99991),Array(99991)]; - * - * let rowIdx = 0; - * - * for (let i = 0; i < matched.length; i++) { - * let [lidx, ridxs] = matched[i]; - * - * for (let j = 0; j < ridxs.length; j++, rowIdx++) { - * let ridx = ridxs[j]; - * joined[0][rowIdx] = ltable[0][lidx]; - * joined[1][rowIdx] = ltable[1][lidx]; - * joined[2][rowIdx] = rtable[1][ridx]; - * } - * } - * - * for (let i = 0; i < unmatchedLeft.length; i++, rowIdx++) { - * let lidx = unmatchedLeft[i]; - * joined[0][rowIdx] = ltable[0][lidx]; - * joined[1][rowIdx] = ltable[1][lidx]; - * joined[2][rowIdx] = null; - * } - * - * for (let i = 0; i < unmatchedRight.length; i++, rowIdx++) { - * let ridx = unmatchedRight[i]; - * joined[0][rowIdx] = rtable[0][ridx]; - * joined[1][rowIdx] = null; - * joined[2][rowIdx] = rtable[1][ridx]; - * } - * - * return joined; - * } + * 1. First flatten the matched/unmatched bookkeeping into a per-output-row source plan. For each output + * row we record the source row index in the left and right tables (-1 means "no source", i.e. null-fill). + * 2. Then fill each output column independently with a single tight, monomorphic loop over the plan. This + * keeps reads/writes sequential within one array at a time, which both avoids the per-cell column + * dispatch and is friendlier to the cache than scattering writes across every column on each row. */ // console.time('materialize'); - let outFieldsTpl = Array.from({ length: ltable.length + rtable.length - 1 }, () => `Array(${count})`).join(','); - let copyLeftRowTpl = ltable.map((c, i) => `joined[${i}][rowIdx] = ltable[${i}][lidx]`).join(';'); - // (skips join field in right table) - let copyRightRowTpl = rtable - .slice(1) - .map((c, i) => `joined[${ltable.length + i}][rowIdx] = rtable[${i + 1}][ridx]`) - .join(';'); - - // for outer joins, when we null-fill the left row values, we still populate the first (join) column - // with the right row's join column value, rather than omitting it as we do for matched left/right where - // that value is already filled by the left row - let nullLeftRowTpl = ltable - .map((c, i) => `joined[${i}][rowIdx] = ${i === 0 ? `rtable[${i}][ridx]` : `null`}`) - .join(';'); - // (skips join field in right table) - let nullRightRowTpl = rtable.slice(1).map((c, i) => `joined[${ltable.length + i}][rowIdx] = null`); - - let materialize = new Function( - 'matched', - 'unmatchedLeft', - 'unmatchedRight', - 'ltable', - 'rtable', - ` - const joined = [${outFieldsTpl}]; - - let rowIdx = 0; - - for (let i = 0; i < matched.length; i++) { - let [lidx, ridxs] = matched[i]; - - for (let j = 0; j < ridxs.length; j++, rowIdx++) { - let ridx = ridxs[j]; - ${copyLeftRowTpl}; - ${copyRightRowTpl}; - } - } + let leftSrc = new Int32Array(count); + let rightSrc = new Int32Array(count); - for (let i = 0; i < unmatchedLeft.length; i++, rowIdx++) { - let lidx = unmatchedLeft[i]; - ${copyLeftRowTpl}; - ${nullRightRowTpl}; - } + let rowIdx = 0; - for (let i = 0; i < unmatchedRight.length; i++, rowIdx++) { - let ridx = unmatchedRight[i]; - ${nullLeftRowTpl}; - ${copyRightRowTpl}; + for (let i = 0; i < matched.length; i++) { + let [lidx, ridxs] = matched[i]; + + for (let j = 0; j < ridxs.length; j++, rowIdx++) { + leftSrc[rowIdx] = lidx; + rightSrc[rowIdx] = ridxs[j]; } + } - return joined; - ` - ); + for (let i = 0; i < unmatchedLeft.length; i++, rowIdx++) { + leftSrc[rowIdx] = unmatchedLeft[i]; + rightSrc[rowIdx] = -1; + } + + for (let i = 0; i < unmatchedRight.length; i++, rowIdx++) { + leftSrc[rowIdx] = -1; + rightSrc[rowIdx] = unmatchedRight[i]; + } + + let joined: Array<Array<number | string | null | undefined>> = []; + + // join column (output col 0): sourced from the left row when present, otherwise from the matched + // right row. Every output row has exactly one side, so this always resolves to a real value. + let ljoin = ltable[0]; + let rjoin = rtable[0]; + let joinCol = Array(count); + for (let r = 0; r < count; r++) { + let li = leftSrc[r]; + joinCol[r] = li !== -1 ? ljoin[li] : rjoin[rightSrc[r]]; + } + joined.push(joinCol); + + // remaining left columns: null-filled for unmatched-right rows + for (let c = 1; c < ltable.length; c++) { + let src = ltable[c]; + let col = Array(count); + for (let r = 0; r < count; r++) { + let li = leftSrc[r]; + col[r] = li !== -1 ? src[li] : null; + } + joined.push(col); + } - let joined = materialize(matched, unmatchedLeft, unmatchedRight, ltable, rtable); + // right columns (skip the join field): null-filled for unmatched-left rows + for (let c = 1; c < rtable.length; c++) { + let src = rtable[c]; + let col = Array(count); + for (let r = 0; r < count; r++) { + let ri = rightSrc[r]; + col[r] = ri !== -1 ? src[ri] : null; + } + joined.push(col); + } // console.timeEnd('materialize'); - ltable = joined; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + ltable = joined as AlignedData; lfield = ltable[0]; }
← Back to Alerts View on GitHub →