Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
deb00e1
feat(http): strict RFC 3986 encoders, originHost and an exported loop…
cevheri Oct 9, 2026
88c63f5
feat(http): refuse link-local and AWS IPv6 metadata addresses by lite…
cevheri Oct 9, 2026
2728f71
feat(http): TransportError carries an optional redirect detail
cevheri Oct 9, 2026
ecb568c
refactor(http): share one Agent and queue core under createNodeTransport
cevheri Oct 9, 2026
d083bc6
feat(http): a byte transport for GET and HEAD to an exact request target
cevheri Oct 9, 2026
59bb509
fix(http): the byte target cap counts the bytes written, the ? only w…
cevheri Oct 9, 2026
058fefa
feat(http): the byte transport returns only the response headers its …
cevheri Oct 9, 2026
7225a44
feat(http): truncateAt keeps the first bytes of a longer body and rep…
cevheri Oct 9, 2026
f39c2f6
feat(http): a refused redirect on the byte transport carries its stat…
cevheri Oct 9, 2026
201b10e
feat(http): a synchronous per-request signer on the byte transport
cevheri Oct 9, 2026
b952682
feat(http): the byte transport never reaches a link-local or AWS IPv6…
cevheri Oct 9, 2026
8e0a698
test(http): run the byte transport's cases on Bun, Node 24 and Node 2…
cevheri Oct 9, 2026
f6050bc
docs(http): describe the byte transport in the module, the provider g…
cevheri Oct 9, 2026
d74d621
docs(http): name the response fields the byte transport always returns
cevheri Oct 9, 2026
6c8357e
docs(backlog): file D254, DOC19 and DOC20
cevheri Oct 9, 2026
fca239f
fix(http): the byte transport reads a request's fields once
cevheri Oct 9, 2026
08ca7f4
fix(http): a zoned IPv6 address is refused as link-local
cevheri Oct 9, 2026
5c1b53b
fix(http): the signer's names are read once and checked before the Ag…
cevheri Oct 9, 2026
143630f
fix(http): the text transport checks a settled request before it deco…
cevheri Oct 9, 2026
ccdf835
docs(http): state what the encoders, a refused 3xx and the cut path d…
cevheri Oct 9, 2026
32a7b93
test(http): cover the encoder edges and a truncated redirect, and dro…
cevheri Oct 9, 2026
3fb081c
docs(backlog): file D255 and correct D254 and DOC20
cevheri Oct 9, 2026
cc6db92
docs(http): say the selection is checked before the core, and name D2…
cevheri Oct 9, 2026
f64e49d
Merge remote-tracking branch 'origin/main' into feat/s3-transport
cevheri Oct 9, 2026
46ccfd2
fix(http): drain the socket queue in one loop, so a run of failed sta…
cevheri Oct 9, 2026
f16ebeb
fix(http): never sign or send a byte request taken from the queue alr…
cevheri Oct 9, 2026
e9039fa
fix(http): close the byte transport's remaining review findings
cevheri Oct 9, 2026
f1c5a33
docs(http): say that the byte transport's connection headers never ca…
cevheri Oct 9, 2026
7bda315
docs(backlog): file D256 to D260 for the text-path findings, extend D…
cevheri Oct 9, 2026
7c92ce0
fix(http): hold a byte connection header name to the token rule as wr…
cevheri Oct 9, 2026
568432a
Merge remote-tracking branch 'origin/main' into feat/s3-transport
cevheri Oct 9, 2026
730ab5a
fix(http): refuse a 101 that reaches the byte answer callback
cevheri Oct 9, 2026
5da28b0
test(http): pin the byte transport's 101 refusals, dot-segment target…
cevheri Oct 9, 2026
333206d
docs(http): say that contentEncoding joins repeated values, and pin it
cevheri Oct 9, 2026
ca5f17a
docs(backlog): add the IPv4-compatible metadata form to D254 and the …
cevheri Oct 9, 2026
ddcc8d2
Merge remote-tracking branch 'origin/main' into feat/s3-transport
cevheri Oct 9, 2026
f22dd50
Merge remote-tracking branch 'origin/main' into feat/s3-transport
cevheri Oct 9, 2026
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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,20 @@ jobs:
- name: Enforce 100% line coverage
run: bun run coverage:check

# Production runs on Node 26.10.0 (the Dockerfile's runner stage), and the byte transport's refusal of a
# link-local DNS answer with DB_HTTP_BLOCK_PRIVATE_HOSTS off rests on that runtime's Agent using the
# transport's own lookup, so the transport's runtime cases run once more on exactly that version. The suite
# and its coverage above stay on Node 24.
- name: Setup Node 26.10.0 for the transport runtime cases
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "26.10.0"

- name: Run the transport runtime cases on Node 26.10.0
run: |
test "$(node --version)" = "v26.10.0"
NODE_TRANSPORT_NODES="$(command -v node)" bun tests/run-tests.ts tests/unit/db/http/node-transport-runtimes.test.ts

- name: Upload coverage artifact
# always(): keep the lcov available for debugging when coverage:check
# fails for a non-obvious reason (e.g. malformed report, merge bug).
Expand Down
6 changes: 6 additions & 0 deletions docs/ADDING_A_PROVIDER.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ It dials through `node:http` or `node:https` with one keep-alive Agent per conne
It maps the SSL / TLS panel through `nodeTlsMaterial`, the one TLS mapping a new provider takes, and checks the certificate against the far end of an SSH tunnel rather than the local forward.
With `DB_HTTP_BLOCK_PRIVATE_HOSTS` on, the egress guard's lookup runs on that Agent, so pooled sockets stay guarded.
A provider that needs headers per request lists their lower-case names in `requestHeaderNames` and passes them in `NodeRequest.headers`; a name the transport or the connection owns is refused when the transport is built.
A provider that reads stored objects instead of statement results builds on `createNodeByteTransport` in the same file.
It sends GET and HEAD only, to an exact request target built with `rfc3986Path` and `rfc3986Query` from [`endpoint.ts`](../src/lib/db/http/endpoint.ts), and returns the body as bytes, never decoded, with an optional `truncateAt` that keeps the first bytes and says so.
Besides `contentType`, `contentEncoding` and `retryAfter`, it returns only the response headers its connection selects, never Location or Set-Cookie, and on a refused redirect it carries the status and those headers without following it.
A `signer` is called once per request just before it is written, with the exact method, Host, path, query and headers, and adds only the header names it lists.
Its connection headers meet the rule a request's own headers meet, so `authorization` comes from the signer alone.
It never reaches a link-local address or AWS's IPv6 instance metadata address, whatever `DB_HTTP_BLOCK_PRIVATE_HOSTS` says.
The older HTTP providers keep their own transports until D37 in [`BACKLOG.md`](BACKLOG.md) moves them.

**Send gRPC calls through the shared gRPC transport.**
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ src/
│ │ # cypher/ (lexer, statements, quoting, read policy, generators), objects.ts, values.ts and
│ │ # profile.ts are pure and browser-safe; bolt/ (the GraphClient seam, the URI, the one
│ │ # neo4j-driver-lite client, driver values to JSON) and graph-base-provider.ts are server only
│ ├── http/ # endpoint.ts: the validated URL builder every HTTP transport uses (no redirects); node-transport.ts: the shared node:http(s) transport a new REST provider takes (one keep-alive Agent per connection, no proxy variables, a streamed byte cap)
│ ├── http/ # endpoint.ts: the validated URL builder every HTTP transport uses (no redirects); node-transport.ts: the shared node:http(s) transport a new REST provider takes (one keep-alive Agent per connection, no proxy variables, a streamed byte cap); node-transport.ts also holds the byte transport (GET and HEAD to an exact target, bytes, selected headers, a per-request signer)
│ ├── grpc/ # channel.ts: the one gRPC channel (options, unary and bidirectional calls, deadlines, aborts, the sent or unsent notice); credentials.ts: TLS credentials and the closing wrapper; tls.ts: the SSL / TLS panel, the TLS identity and the dial target, for every gRPC provider
│ ├── factory.ts # Provider factory
│ ├── query-dialects.ts # The dialect registry: each queryDialect's tab type and row-menu answers
Expand Down
99 changes: 97 additions & 2 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ None of it is a GitHub issue.
**Sections**

- [SQL statement reading](#sql-statement-reading) — S2–S7 · 5
- [Drivers and connections](#drivers-and-connections) — D1-D253, U17 · 157
- [Drivers and connections](#drivers-and-connections) — D1-D260, U17 · 164
- [Value interpolation](#value-interpolation) — V1
- [Row editing](#row-editing) — R1–R3 · 3
- [Studio UI and query execution](#studio-ui-and-query-execution) — X2-X27, U2-U108 · 99
- [Dependencies](#dependencies) — P1-P9 · 7
- [Documentation](#documentation) — DOC3-DOC18 · 15
- [Documentation](#documentation) — DOC3-DOC20 · 17
- [Release pipeline](#release-pipeline) — REL1-REL8 · 8
- [Chart configuration surface](#chart-configuration-surface) — N1 · 1
- [Security Phase 1 deferrals](#security-phase-1-deferrals) — H1–H14 · 4
Expand Down Expand Up @@ -2730,6 +2730,84 @@ Found 2026-10-08 while fixing the red-team findings on the Databend provider's t

**Done when:** a request stopped before it was handed a socket fails in a way its caller can tell from one stopped after it was sent, by a kind or a flag of its own, as `truncated` marks a cut answer, with a node-transport test that runs two requests under `maxSockets: 1`, stops the queued one, and asserts its failure and that the server received one request, and the Databend transport reads that failure as a stop with nothing sent, with no kill, no logout and `cancelled` or `timeout`, tested.

### D254. Metadata services outside link-local networks are not refused

The byte transport refuses the entries of `LINK_LOCAL_NETWORKS` in `src/lib/db/http/egress-policy.ts` (`169.254.0.0/16` with its IPv4-mapped form and its NAT64 form under the well-known prefix `64:ff9b::/96`, `fe80::/10`, `fd00:ec2::254`) whatever `DB_HTTP_BLOCK_PRIVATE_HOSTS` says (`docs/SECURITY.md` row 0.6).
A metadata service a cloud serves at any other address is reachable with the guard off, and no primary source for such addresses was read when the list was written.
The first candidate to verify is Alibaba Cloud's `100.100.100.200`, reported by a reviewer and not yet sourced; it sits in CGNAT `100.64.0.0/10`, which the byte transport refuses only when `DB_HTTP_BLOCK_PRIVATE_HOSTS` is on.
The second is `169.254.0.0/16` behind the NAT64 local-use prefix `64:ff9b:1::/48`, which the guard's own list holds and `LINK_LOCAL_NETWORKS` does not, so with the guard off the byte transport reaches it.
The third is the IPv4-compatible form `::169.254.169.254`, which `LINK_LOCAL_NETWORKS` does not match on Bun 1.4.2, Node 24.14 or Node 26.10 (measured 2026-10-10 with the same `BlockList`); the kernel it was probed on does not route `::a.b.c.d` to IPv4, and no primary source says whether any platform does.

Found 2026-10-09 while designing the S3 provider.

**Done when:** each named cloud's documented address is read from a saved primary source and added to `LINK_LOCAL_NETWORKS` or recorded as reachable.

### D255. The shared text transport reads a request's cap again after checking it

`createNodeTransport` in `src/lib/db/http/node-transport.ts` checks `request.maxResponseBytes` in `request()` and then reads it from the caller's object again for every body chunk, so a field that answers differently on a later read sets a cap that was never checked.
Measured 2026-10-10 on Bun 1.4.2 against a raw local listener: a `maxResponseBytes` getter that answered 100 on its first read and 2 MiB afterwards was read five times, and a 1 MiB body was returned whole instead of failing as `too-large`.
The same path never checks `method` at all, so a caller that passes `"DELETE"` past the type sends `DELETE` (measured on the same run).
It also refuses a closed transport and a cancelled signal before it reads the caller's headers, so a header getter that cancels the signal or closes the transport is sent anyway: measured 2026-10-10 on Bun 1.4.2 against a local `node:http` server, both requests resolved and the server received both.
`createNodeByteTransport` reads each field once, checks the method, and admits a request again once every field has been read; no provider passes such an object today, so this is hardening.

Found 2026-10-10 by the whole-branch review of the S3 byte transport; pre-existing in the text transport.

**Done when:** the text transport reads `method`, `url`, `body`, `form`, `headers`, `signal` and `maxResponseBytes` once, refuses a method other than GET and POST before any socket, refuses a closed transport and a cancelled signal after those reads, and sends only what it checked, with a test per field whose getter answers differently on its second read and a test whose header getter cancels the signal and one whose getter closes the transport, each asserting that nothing is sent.

### D256. A form the text transport cannot serialise leaks its socket slot

`createNodeTransport` in `src/lib/db/http/node-transport.ts` serialises a request's `form` with `payloadOf` after the request has taken its socket slot and outside the try that frees it, so a `form` that `URLSearchParams` cannot read rejects with the raw TypeError and the slot is never freed.
Measured 2026-10-10 on Bun 1.4.2 with `maxSockets: 1` against a local `node:http` server: a POST whose form held a Symbol value rejected with "TypeError: Cannot convert a Symbol value to a string", and the next GET on the same transport waited until its 1.5 s deadline and failed as a timeout; origin/main behaves the same.
Queued behind another request it is worse: the TypeError is thrown when the earlier request's answer frees the slot, so it escapes from that request's `end` listener as an uncaught exception, which ends a Node process with no handler, the earlier request never resolves, the queued one never settles, and the next request times out; measured the same day on Bun 1.4.2 and Node 24.14.0, on origin/main as well.
No provider passes a form value that is not a string today, so this is hardening.

Found 2026-10-10 by the adversarial review of the S3 byte transport; pre-existing in the text transport.

**Done when:** a form that cannot be serialised is refused before any socket slot is taken, with a `DatabaseConfigError` that names no value, and a node-transport test under `maxSockets: 1` asserts that refusal and that the next request completes.

### D257. With the egress guard on, the text transport dials a zoned link-local DNS answer on Bun

With `DB_HTTP_BLOCK_PRIVATE_HOSTS` on, the text transport's lookup is the guard's `publicAddressLookup`, whose check `assertPublicDnsAnswers` in `src/lib/db/http/egress-policy.ts` has no zone rule, and Bun's `BlockList` does not match a zoned address such as `fe80::1%lo` against `fe80::/10`, where Node's does.
Measured 2026-10-10 on Bun 1.4.2 with `node:dns` mocked to answer `fe80::1%lo`: the text transport dialled it and failed with ECONNREFUSED instead of the guard's refusal.
The byte transport runs the link-local check, which refuses any zoned IPv6 answer, after the guard's (`guardedLinkLocalRefusingLookup`), so it refuses the same answer.

Found 2026-10-10 by the adversarial review of the S3 byte transport; pre-existing in the guard.

**Done when:** the guard's answer check refuses an IPv6 answer with a zone index on every runtime, with an egress-policy test that mocks `node:dns` to answer `fe80::1%lo` and asserts the guard's sentence and that no socket is opened.

### D258. The text transport sends a queued request whose signal fired with the request ahead of it

When one signal cancels a running request and the requests queued behind it, `EventTarget` calls their abort listeners in order, so the running request's failure frees its socket slot and the shared queue in `src/lib/db/http/node-transport.ts` starts the next request before that request's own listener has run.
`createNodeTransport` then hands it to the Agent, which dials a socket for it, and only then is it destroyed, so a cancel still costs a connection and its lookup.
Measured 2026-10-10 on Bun 1.4.2 and Node 24.14 with `maxSockets: 1`: four GETs on one AbortController against a raw listener that never answers all failed as `aborted`, and the listener accepted a second, empty connection after the abort.
`createNodeByteTransport` fails such a request as cancelled before it is signed or handed to the Agent.

Found 2026-10-10 by the adversarial review of the S3 byte transport; pre-existing in the text transport.

**Done when:** the text transport fails a request taken from the queue with its signal already aborted before it is handed to the Agent, with the same kind and message as today, and a node-transport test with four requests on one signal under `maxSockets: 1` asserts that the listener accepts one connection.

### D259. A 101 answer never settles a text transport request

node:http hands a `101 Switching Protocols` answer to the request's `upgrade` event, never to the response callback, and `createNodeTransport` in `src/lib/db/http/node-transport.ts` has no `upgrade` listener, so the request holds its socket slot until its deadline and is reported as a timeout, or forever without one.
Measured 2026-10-10 on Bun 1.4.2 and Node 24.14 against a raw listener that answers every request with a 101: a GET with a 1.5 s deadline failed as `timeout` after 1501 ms.
`createNodeByteTransport` refuses a 101 at once as a network failure and destroys the switched socket.

Found 2026-10-10 by the adversarial review of the S3 byte transport; pre-existing in the text transport.

**Done when:** the text transport fails a 101 answer at once as a `network` failure and destroys its socket, with a node-transport test that asserts the failure arrives well before the deadline and that the next request under `maxSockets: 1` completes.

### D260. A request that fails in flight frees its slot while the Agent still counts its socket

When a request fails after its socket was opened, by a cancel, a deadline or the byte cap, the shared queue in `src/lib/db/http/node-transport.ts` frees its slot at once, but the Agent counts the destroyed socket against `maxSockets` until it has closed, so the next queued request goes to the Agent's own queue, where Node dials a socket even for a request cancelled there.
Measured 2026-10-10 on Node 24.14 with the byte transport and `maxSockets: 1`: in ten rounds of a GET that failed as `too-large` followed by a queued GET cancelled as the first failed, the listener accepted ten empty connections besides the ten requests; Bun 1.4.2 accepted one.
The text transport shares the queue, so it behaves the same.
On the byte transport that next request is signed and handed to the Agent while the Agent still counts the failed request's socket, so it is signed before it reaches a socket; a SigV4 signature stays valid for 15 minutes, so the wait costs no signature.
A byte answer cut by `truncateAt` already frees its slot only once its request has closed (`resolveCut`).

Found 2026-10-10 while fixing the adversarial review of the S3 byte transport; pre-existing in the shared queue.

**Done when:** every failure that destroys a request's socket frees the slot only once that request has closed, as `resolveCut` does, with every text outcome unchanged, and the runtimes test counts no empty connection after a failed request followed by a queued one cancelled as it fails.

## Value interpolation

### V1. Query history records the placeholders, not the values that were bound
Expand Down Expand Up @@ -4693,6 +4771,23 @@ Not fixed there: that work runs the scan with those two mounts and counts `0 com

**Done when:** `CONTRIBUTING.md` gives a command that scans the branch from a git worktree as well as from a clone, for example by mounting the worktree and the main checkout's `.git` at their own absolute paths, and says that a scan reporting `0 commits scanned.` checked nothing.

### DOC19. CLAUDE.md names a mongodb branch that moved and a Redis method that no longer exists

`CLAUDE.md` (Architecture) lists `src/lib/editor/tab-language.ts` among the three UI files that keep a `=== "mongodb"` branch, but that file has none; the branches are in `src/hooks/use-connection-form.ts` (`type === "mongodb" && authSource`) and `src/components/ConnectionModal.tsx` (`isMongoDB`).
`CLAUDE.md` (Database Connections) says Redis `getSchema()` uses a non-blocking `SCAN`, but `getSchema` left the provider contract in #789 (`src/lib/db/base-provider.ts`), and comments in `src/lib/db/providers/keyvalue/redis.ts` still name it.

Found 2026-10-09 while designing the S3 provider; pre-existing.

**Done when:** `CLAUDE.md` names the files that hold the branches today and describes the Redis key walk by the method that exists, and the Redis comments name it too.

### DOC20. docs/ADDING_A_PROVIDER.md carries stale counts and an incomplete label table

The guide says seven translated READMEs are gated by `readme:check` where `scripts/readme-check.mjs` gates eight (`README_ko.md` joined in 3d9689aaf); its `getCapabilities()` bullet names two query languages (`sql` | `json`) where its own `ProviderCapabilities` table lists all five; says `QueryEditor` registers six language modules where it registers seven plus `registerDialectConsoles`; has no row for `sessionsEmptyState`, `tableStatsCaption` or `vacuumActionOperation` in its `ProviderLabels` table; and gives two driver-free counts, thirteen in its first decision and fifteen in its checklist.

Found 2026-10-09 while designing the S3 provider; pre-existing.

**Done when:** each count is derived from the code it describes, the label table matches `ProviderLabels`, and the guide states one driver-free count with the list it counts.

## Release pipeline

### REL1. No CI job installs the released chart artifact with a Helm 3 client
Expand Down
Loading
Loading