@reddb-io/client 1.23.2 → 1.23.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -225,3 +225,11 @@ MIT.
225
225
 
226
226
  _Status legend: ✅ supported · ⚠️ partial (known gaps) · ❌ unsupported._
227
227
  <!-- contract-matrix:end -->
228
+
229
+ ## SQL quotation compatibility
230
+
231
+ SQL double quotes delimit identifiers; single quotes delimit text. For example,
232
+ `SELECT "title" FROM articles WHERE author = 'alice'` reads the `title` column.
233
+ Bind application values as parameters. JSON object/array strings keep double
234
+ quotes. See the [SQL quotation migration guide](../../docs/query/sql-quoting.md)
235
+ before upgrading applications that used double-quoted SQL text literals.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reddb-io/client",
3
- "version": "1.23.2",
3
+ "version": "1.23.4",
4
4
  "description": "Thin remote-only RedDB driver. Downloads the `red_client` binary on install. Speaks RedWire/gRPC/HTTP. Embedded URIs (memory://, file://, red:///path) are rejected — use @reddb-io/sdk for those.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/config.js CHANGED
@@ -62,5 +62,5 @@ function keyedValueLiteral(value) {
62
62
  }
63
63
 
64
64
  function keyedStringLiteral(value) {
65
- return `'${String(value).replace(/'/g, "''")}'`
65
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
66
66
  }
package/src/db-helpers.js CHANGED
@@ -91,5 +91,5 @@ function sqlIdentifier(value) {
91
91
  }
92
92
 
93
93
  function sqlString(value) {
94
- return `'${String(value).replace(/'/g, "''")}'`
94
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
95
95
  }
package/src/documents.js CHANGED
@@ -127,5 +127,5 @@ function sqlValueLiteral(value) {
127
127
  }
128
128
 
129
129
  function sqlString(value) {
130
- return `'${String(value).replace(/'/g, "''")}'`
130
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
131
131
  }
@@ -3,6 +3,71 @@ import { request as httpRequest } from 'node:http'
3
3
 
4
4
  const MAX_REDIRECTS = 5
5
5
 
6
+ /**
7
+ * Hosts a release download may be served from. The postinstall script writes
8
+ * the response to disk and then executes it, so an unrestricted redirect
9
+ * chain (or a plain-http hop) turns any network position into code
10
+ * execution on the installing machine. GitHub serves release assets from
11
+ * `github.com` and redirects to its object storage.
12
+ */
13
+ const ALLOWED_HOSTS = new Set([
14
+ 'github.com',
15
+ 'objects.githubusercontent.com',
16
+ 'release-assets.githubusercontent.com',
17
+ 'raw.githubusercontent.com',
18
+ ])
19
+
20
+ /**
21
+ * Ceiling on a downloaded asset. The `red` binary is tens of megabytes; a
22
+ * response without this cap is buffered in full whatever its size.
23
+ */
24
+ const MAX_BODY_BYTES = 512 * 1024 * 1024
25
+
26
+ export class DisallowedHostError extends Error {
27
+ constructor(url) {
28
+ super(`refusing to download from a non-allowlisted host: ${url}`)
29
+ this.name = 'DisallowedHostError'
30
+ this.code = 'DISALLOWED_HOST'
31
+ this.url = url
32
+ }
33
+ }
34
+
35
+ export class ResponseTooLargeError extends Error {
36
+ constructor(url, limit) {
37
+ super(`response from ${url} exceeds the ${limit}-byte limit`)
38
+ this.name = 'ResponseTooLargeError'
39
+ this.code = 'RESPONSE_TOO_LARGE'
40
+ this.url = url
41
+ this.limit = limit
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Reject anything that is not https on an allowlisted host. Applied to the
47
+ * initial URL and to every redirect target, since a redirect is exactly how
48
+ * an attacker would leave the allowlist.
49
+ *
50
+ * `allowedHosts` exists so the test suite can point the transport at a local
51
+ * server; production callers never pass it and get {@link ALLOWED_HOSTS}
52
+ * plus the https requirement.
53
+ */
54
+ export function assertDownloadableUrl(url, allowedHosts = ALLOWED_HOSTS) {
55
+ let parsed
56
+ try {
57
+ parsed = new URL(url)
58
+ } catch {
59
+ throw new DisallowedHostError(url)
60
+ }
61
+ const isDefaultPolicy = allowedHosts === ALLOWED_HOSTS
62
+ if (isDefaultPolicy && parsed.protocol !== 'https:') {
63
+ throw new DisallowedHostError(url)
64
+ }
65
+ if (!allowedHosts.has(parsed.hostname)) {
66
+ throw new DisallowedHostError(url)
67
+ }
68
+ return parsed
69
+ }
70
+
6
71
  export class AssetNotFoundError extends Error {
7
72
  constructor(url) {
8
73
  super(`asset not found (HTTP 404) at ${url}`)
@@ -31,21 +96,27 @@ export class TooManyRedirectsError extends Error {
31
96
  }
32
97
  }
33
98
 
34
- function pickRequest(url) {
35
- return url.startsWith('http://') ? httpRequest : httpsRequest
36
- }
37
-
38
99
  function resolveLocation(currentUrl, location) {
39
100
  if (/^https?:\/\//i.test(location)) return location
40
101
  return new URL(location, currentUrl).toString()
41
102
  }
42
103
 
43
- export function downloadFollowingRedirects(url, { userAgent, originalUrl } = {}, depth = 0) {
104
+ export function downloadFollowingRedirects(
105
+ url,
106
+ { userAgent, originalUrl, allowedHosts } = {},
107
+ depth = 0,
108
+ ) {
44
109
  const startUrl = originalUrl || url
45
110
  if (depth > MAX_REDIRECTS) {
46
111
  return Promise.reject(new TooManyRedirectsError(startUrl))
47
112
  }
48
- const request = pickRequest(url)
113
+ let parsed
114
+ try {
115
+ parsed = assertDownloadableUrl(url, allowedHosts)
116
+ } catch (err) {
117
+ return Promise.reject(err)
118
+ }
119
+ const request = parsed.protocol === 'http:' ? httpRequest : httpsRequest
49
120
  return new Promise((resolve, reject) => {
50
121
  const req = request(
51
122
  url,
@@ -61,10 +132,11 @@ export function downloadFollowingRedirects(url, { userAgent, originalUrl } = {},
61
132
  if (status >= 300 && status < 400 && res.headers.location) {
62
133
  res.resume()
63
134
  const next = resolveLocation(url, res.headers.location)
64
- downloadFollowingRedirects(next, { userAgent, originalUrl: startUrl }, depth + 1).then(
65
- resolve,
66
- reject,
67
- )
135
+ downloadFollowingRedirects(
136
+ next,
137
+ { userAgent, originalUrl: startUrl, allowedHosts },
138
+ depth + 1,
139
+ ).then(resolve, reject)
68
140
  return
69
141
  }
70
142
  if (status === 404) {
@@ -78,7 +150,16 @@ export function downloadFollowingRedirects(url, { userAgent, originalUrl } = {},
78
150
  return
79
151
  }
80
152
  const chunks = []
81
- res.on('data', (chunk) => chunks.push(chunk))
153
+ let received = 0
154
+ res.on('data', (chunk) => {
155
+ received += chunk.length
156
+ if (received > MAX_BODY_BYTES) {
157
+ res.destroy()
158
+ reject(new ResponseTooLargeError(startUrl, MAX_BODY_BYTES))
159
+ return
160
+ }
161
+ chunks.push(chunk)
162
+ })
82
163
  res.on('end', () => resolve(Buffer.concat(chunks)))
83
164
  res.on('error', reject)
84
165
  },
@@ -26,7 +26,44 @@
26
26
 
27
27
  import { composeAssetName } from './asset-name.js'
28
28
  import { downloadFollowingRedirects } from './download.js'
29
- import { verifySha256 } from './checksum.js'
29
+ import { sha256Hex, verifySha256, ChecksumMismatchError } from './checksum.js'
30
+
31
+ /**
32
+ * A GitHub `owner/name` pair. `repo` reaches this function from
33
+ * `REDDB_POSTINSTALL_REPO` / `REDDB_MCP_REPO`, and is interpolated into the
34
+ * download URL, so a value containing a path or authority would point the
35
+ * download somewhere else entirely.
36
+ */
37
+ const REPO_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/
38
+
39
+ /** A release tag, as it appears in the download URL path. */
40
+ const TAG_PATTERN = /^[A-Za-z0-9._+-]+$/
41
+
42
+ export class ChecksumUnavailableError extends Error {
43
+ constructor(assetName) {
44
+ super(
45
+ `no SHA256SUMS entry for ${assetName}; refusing to install an unverified binary ` +
46
+ `(set REDDB_POSTINSTALL_ALLOW_UNVERIFIED=1 to override)`,
47
+ )
48
+ this.name = 'ChecksumUnavailableError'
49
+ this.code = 'CHECKSUM_UNAVAILABLE'
50
+ this.assetName = assetName
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Parse a `SHA256SUMS` file (`<hex> <filename>` per line) and return the
56
+ * digest recorded for `assetName`, or `null` when it is not listed.
57
+ */
58
+ export function sha256FromSumsFile(text, assetName) {
59
+ for (const line of String(text).split('\n')) {
60
+ const match = line.trim().match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/)
61
+ if (match && match[2].trim() === assetName) {
62
+ return match[1].toLowerCase()
63
+ }
64
+ }
65
+ return null
66
+ }
30
67
 
31
68
  export async function fetchReleaseAsset({ repo, tag, platform, arch, binName, sha256 } = {}) {
32
69
  if (typeof repo !== 'string' || repo === '') {
@@ -42,11 +79,52 @@ export async function fetchReleaseAsset({ repo, tag, platform, arch, binName, sh
42
79
  throw new TypeError('fetchReleaseAsset: `arch` must be a non-empty string')
43
80
  }
44
81
 
82
+ if (!REPO_PATTERN.test(repo)) {
83
+ throw new TypeError(`fetchReleaseAsset: \`repo\` must be "owner/name", got ${JSON.stringify(repo)}`)
84
+ }
85
+ if (!TAG_PATTERN.test(tag)) {
86
+ throw new TypeError(`fetchReleaseAsset: \`tag\` contains unsupported characters: ${JSON.stringify(tag)}`)
87
+ }
88
+
45
89
  const assetName = composeAssetName({ platform, arch, binName })
46
- const url = `https://github.com/${repo}/releases/download/${tag}/${assetName}`
47
- const body = await downloadFollowingRedirects(url)
90
+ const base = `https://github.com/${repo}/releases/download/${tag}`
91
+ const body = await downloadFollowingRedirects(`${base}/${assetName}`)
92
+
93
+ // The caller may pin a digest; otherwise fall back to the `SHA256SUMS`
94
+ // file the release publishes beside the assets. The downloaded bytes are
95
+ // written to disk and executed, so installing them unverified is the one
96
+ // outcome worth failing the install over — `install.sh` has verified since
97
+ // it shipped, and this path had not.
48
98
  if (sha256) {
49
99
  verifySha256(body, sha256)
100
+ return body
101
+ }
102
+
103
+ let expected = null
104
+ try {
105
+ const sums = await downloadFollowingRedirects(`${base}/SHA256SUMS`)
106
+ expected = sha256FromSumsFile(sums.toString('utf8'), assetName)
107
+ } catch {
108
+ expected = null
50
109
  }
51
- return body
110
+ if (expected) {
111
+ if (sha256Hex(body) !== expected) {
112
+ throw new ChecksumMismatchError(expected, sha256Hex(body))
113
+ }
114
+ return body
115
+ }
116
+ if (allowUnverified()) {
117
+ return body
118
+ }
119
+ throw new ChecksumUnavailableError(assetName)
120
+ }
121
+
122
+ /**
123
+ * Escape hatch for installing from a release that predates `SHA256SUMS`, or
124
+ * from a fork that does not publish one. Opt-in and loud rather than the
125
+ * silent default it used to be.
126
+ */
127
+ function allowUnverified() {
128
+ const raw = process.env.REDDB_POSTINSTALL_ALLOW_UNVERIFIED
129
+ return raw === '1' || raw === 'true' || raw === 'yes'
52
130
  }
package/src/kv.js CHANGED
@@ -113,16 +113,16 @@ function kvIdentifier(value) {
113
113
  function kvKeySegment(value) {
114
114
  const key = String(value)
115
115
  if (/^[A-Za-z0-9_]+$/.test(key)) return key
116
- return `'${key.replace(/'/g, "''")}'`
116
+ return `'${key.replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
117
117
  }
118
118
 
119
119
  function kvValueLiteral(value) {
120
120
  if (typeof value === 'number' || typeof value === 'boolean') return String(value)
121
121
  if (value == null) return 'NULL'
122
- if (typeof value === 'object') return `'${JSON.stringify(value).replace(/'/g, "''")}'`
123
- return `'${String(value).replace(/'/g, "''")}'`
122
+ if (typeof value === 'object') return `'${JSON.stringify(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
123
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
124
124
  }
125
125
 
126
126
  function kvTagLiteral(value) {
127
- return `'${String(value).replace(/'/g, "''")}'`
127
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
128
128
  }
package/src/queue.js CHANGED
@@ -114,7 +114,7 @@ function queueValueLiteral(value) {
114
114
  }
115
115
 
116
116
  function queueStringLiteral(value) {
117
- return `'${String(value).replace(/'/g, "''")}'`
117
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
118
118
  }
119
119
 
120
120
  function queuePayloads(result) {
package/src/vault.js CHANGED
@@ -54,5 +54,5 @@ function keyedValueLiteral(value) {
54
54
  }
55
55
 
56
56
  function keyedStringLiteral(value) {
57
- return `'${String(value).replace(/'/g, "''")}'`
57
+ return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "''")}'`
58
58
  }