Skip to content

fix: harden sync, storage and pagination against untrusted client input - #47

Open
MoatazNoaman2001 wants to merge 4 commits into
concile-dev:mainfrom
MoatazNoaman2001:fix/security-hardening
Open

MoatazNoaman2001 wants to merge 4 commits into
concile-dev:mainfrom
MoatazNoaman2001:fix/security-hardening

Conversation

@MoatazNoaman2001

Copy link
Copy Markdown

Summary

Three fixes for paths where untrusted client input reaches the server unchecked. Each one is a separate commit with its own changeset and regression tests.

1. A malformed WebSocket frame crashes the server (@concile/sync, @concile/cli, @concile/vite)

parseClientMessage was a bare JSON.parse, called inside the async handleMessage. The Node ws and Bun transports in concile dev/serve and the Vite embed all call handleMessage fire-and-forget (void ...). A single invalid frame from any unauthenticated peer therefore became an unhandled rejection, which exits a Node process. Examples: "x", or {"type":"ModifyQuerySet"}, which throws on for (const q of msg.add).

  • parseClientMessage now shape-checks every client message. It only checks fields a handler dereferences unconditionally; args and event payloads stay opaque. Anything malformed throws ProtocolError.
  • handleMessage answers a ProtocolError with FatalError and closes only that session. An unknown session still rejects, as before.
  • Each transport now catches rejections from handleMessage and closes the offending connection. A future handler bug therefore costs one socket, not the process.

2. Stored XSS via uploaded files (@concile/storage)

handleServe echoed the uploader-supplied content type back with no nosniff and no Content-Disposition. With the default FS blobstore, publicUrl and signGetUrl both return null, so the bytes are streamed from the app's own origin. A file uploaded as text/html or image/svg+xml then ran script with the app's origin, and in dev with the dashboard's origin too.

Streamed files now always carry X-Content-Type-Options: nosniff. Any type outside an inline-safe allowlist (raster images, audio/*, video/*, text/plain, PDF) is served with Content-Disposition: attachment, and so is a missing type. fetch(), <img> and <video> ignore Content-Disposition, so programmatic use is unchanged.

3. A forged pagination cursor reads outside the query's range (@concile/query-engine)

The cursor is raw index-key bytes handed back by the client. paginate used it directly as the scan start (asc) or end (desc), without checking it against the query's own interval. A client could therefore send a cursor below or above an .eq() prefix. For example, .withIndex("by_owner", q => q.eq("owner", me)).paginate(opts) with cursor AA== returned rows belonging to other owners.

The cursor may now only narrow the planned interval. An out-of-range cursor yields the first page or an empty page, and genuine cursors behave exactly as before.

Tests

  • packages/sync/test/malformed-frame.test.ts: 21 malformed frames each produce FatalError and a close for that session only, the other sessions keep working, and every well-formed client shape still parses.
  • packages/storage/test/http.test.ts: active types (text/html, SVG, XHTML, case and parameter variants), a missing type, inline-safe types, and Range responses.
  • packages/query-engine/test/paginate-cursor.test.ts: forged cursors before and after the range in both orders, a cross-query cursor, and normal paging in both orders.

Each new test fails on main and passes with its fix. The full bun run build, bun run typecheck and bun run test pass locally, with one exception: client/test/outbox-fs.test.ts › unopenable journal fails identically on unmodified main. It is a Windows-only EISDIR in a filesystem test and unrelated to these changes.

After you open it, post this comment on the PR to sign the CLA (required on a first PR):
I have read the CLA Document and I hereby sign the CLA

parseClientMessage was a bare JSON.parse, called inside the async
handleMessage with no try/catch. Every Node/Bun transport invokes
handleMessage fire-and-forget (`void ...`), so one invalid frame from
any unauthenticated peer (e.g. "x", or {"type":"ModifyQuerySet"})
became an unhandled rejection and exited the whole process.

- parseClientMessage now shape-checks every client message and throws
  ProtocolError for anything malformed.
- handleMessage answers a ProtocolError with FatalError and closes only
  that session.
- The concile dev/serve (node ws + Bun) and Vite embed transports catch
  any rejection from handleMessage and close the offending connection.
handleServe echoed the uploader-supplied content type back with no
nosniff and no Content-Disposition. With the default FS blobstore the
bytes are streamed from the app's own origin, so an upload labelled
text/html or image/svg+xml ran script with the app's origin (and, in
dev, the dashboard's).

Streamed files now always carry X-Content-Type-Options: nosniff, and
any type outside an inline-safe allowlist (raster images, audio, video,
text/plain, PDF) is served as an attachment.
The cursor is raw index-key bytes returned by the client, and paginate
used it as the new scan start (asc) or end (desc) without checking it
against the query's own interval. A forged cursor could therefore read
rows outside an .eq() prefix, e.g. other owners' rows on a by_owner
index.

The cursor may now only narrow the interval; an out-of-range cursor
yields the first page or an empty one.
@vercel

vercel Bot commented Oct 6, 2026

Copy link
Copy Markdown

@MoatazNoaman2001 is attempting to deploy a commit to the Dibyajyoti's projects Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants