@usequark/quark-core 2.5.6 → 2.5.7
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/package.json +1 -1
- package/src/redis.js +31 -1
- package/src/redis.test.js +63 -1
- package/src/utils.js +41 -0
- package/src/utils.test.js +48 -0
package/package.json
CHANGED
package/src/redis.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { redactUrl } from "./utils.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Builds REDIS_URL from individual environment variables if not explicitly provided.
|
|
3
5
|
* Allows configuration via individual REDIS_* vars instead of a single REDIS_URL.
|
|
@@ -39,6 +41,9 @@ export const createRedisClient = createRedisConfig;
|
|
|
39
41
|
* Dynamically imports ioredis so it doesn't fail at load time if the package is not installed.
|
|
40
42
|
* Creates a temporary connection, sends PING, expects PONG, then disconnects.
|
|
41
43
|
*
|
|
44
|
+
* The `message` on the error path is safe to return from an unauthenticated
|
|
45
|
+
* endpoint: it never contains a password (see `getRedisEndpoint`).
|
|
46
|
+
*
|
|
42
47
|
* @param {object} [options]
|
|
43
48
|
* @param {number} [options.timeout=3000] - Connection/ping timeout in milliseconds.
|
|
44
49
|
* @returns {Promise<{ status: "ok", latencyMs: number } | { status: "error", message: string }>}
|
|
@@ -107,7 +112,7 @@ export async function pingRedis({ timeout = 3000 } = {}) {
|
|
|
107
112
|
error?.code === "ECONNREFUSED" ||
|
|
108
113
|
/connection is closed|ECONNREFUSED/i.test(error?.message ?? "")
|
|
109
114
|
) {
|
|
110
|
-
message = `Redis unreachable at ${
|
|
115
|
+
message = `Redis unreachable at ${getRedisEndpoint()}`;
|
|
111
116
|
} else {
|
|
112
117
|
message = error?.message ?? String(error);
|
|
113
118
|
}
|
|
@@ -121,6 +126,31 @@ export async function pingRedis({ timeout = 3000 } = {}) {
|
|
|
121
126
|
}
|
|
122
127
|
}
|
|
123
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Returns the Redis endpoint as `host:port`, with every credential removed.
|
|
131
|
+
*
|
|
132
|
+
* The message from `pingRedis` is returned verbatim by the unauthenticated
|
|
133
|
+
* `/api/health` route, so it must never carry a password. `getRedisUrl()` is
|
|
134
|
+
* deliberately not used for this: callers need the working connection string,
|
|
135
|
+
* and it embeds `REDIS_URL` credentials as-is.
|
|
136
|
+
*
|
|
137
|
+
* @returns {string} e.g. `redis.railway.internal:6379`
|
|
138
|
+
*/
|
|
139
|
+
export function getRedisEndpoint() {
|
|
140
|
+
const url = getRedisUrl();
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const parsed = new URL(url);
|
|
144
|
+
const port =
|
|
145
|
+
parsed.port || (parsed.protocol === "rediss:" ? "6380" : "6379");
|
|
146
|
+
return `${parsed.hostname}:${port}`;
|
|
147
|
+
} catch {
|
|
148
|
+
// Malformed REDIS_URL — fall back to whatever credentials-safe form the
|
|
149
|
+
// redaction pass can produce rather than echoing the raw value.
|
|
150
|
+
return redactUrl(url);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
124
154
|
/**
|
|
125
155
|
* Resolves Redis connection config as an ioredis-compatible options object.
|
|
126
156
|
* Precedence: explicit override > REDIS_URL > REDIS_HOST/REDIS_PORT > localhost defaults.
|
package/src/redis.test.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
getRedisEndpoint,
|
|
5
|
+
getRedisUrl,
|
|
6
|
+
resolveRedisConnection,
|
|
7
|
+
} from "./redis.js";
|
|
4
8
|
|
|
5
9
|
describe("getRedisUrl", () => {
|
|
6
10
|
let originalEnv;
|
|
@@ -148,3 +152,61 @@ describe("resolveRedisConnection", () => {
|
|
|
148
152
|
assert.equal(result.port, 6382);
|
|
149
153
|
});
|
|
150
154
|
});
|
|
155
|
+
|
|
156
|
+
describe("getRedisEndpoint", () => {
|
|
157
|
+
let originalEnv;
|
|
158
|
+
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
originalEnv = { ...process.env };
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
afterEach(() => {
|
|
164
|
+
process.env = originalEnv;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("never returns the password from REDIS_URL", () => {
|
|
168
|
+
process.env.REDIS_URL = "redis://default:hunter2@cache.internal:6379";
|
|
169
|
+
|
|
170
|
+
const endpoint = getRedisEndpoint();
|
|
171
|
+
|
|
172
|
+
assert.equal(endpoint, "cache.internal:6379");
|
|
173
|
+
assert.ok(!endpoint.includes("hunter2"), "endpoint leaked the password");
|
|
174
|
+
assert.ok(!endpoint.includes("default"), "endpoint leaked the username");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("never returns a password-only credential", () => {
|
|
178
|
+
process.env.REDIS_URL = "redis://:hunter2@cache.internal:6379";
|
|
179
|
+
|
|
180
|
+
const endpoint = getRedisEndpoint();
|
|
181
|
+
|
|
182
|
+
assert.equal(endpoint, "cache.internal:6379");
|
|
183
|
+
assert.ok(!endpoint.includes("hunter2"), "endpoint leaked the password");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("defaults the port to 6379, or 6380 for rediss://", () => {
|
|
187
|
+
process.env.REDIS_URL = "redis://user:pw@cache.internal";
|
|
188
|
+
assert.equal(getRedisEndpoint(), "cache.internal:6379");
|
|
189
|
+
|
|
190
|
+
process.env.REDIS_URL = "rediss://user:pw@secure.internal";
|
|
191
|
+
assert.equal(getRedisEndpoint(), "secure.internal:6380");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("reads host and port from REDIS_HOST/REDIS_PORT when REDIS_URL is unset", () => {
|
|
195
|
+
delete process.env.REDIS_URL;
|
|
196
|
+
process.env.REDIS_HOST = "cache.internal";
|
|
197
|
+
process.env.REDIS_PORT = "6390";
|
|
198
|
+
|
|
199
|
+
assert.equal(getRedisEndpoint(), "cache.internal:6390");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("does not leak a password out of a URL it cannot parse", () => {
|
|
203
|
+
process.env.REDIS_URL = "redis://user:hunter2@cache internal:6379";
|
|
204
|
+
|
|
205
|
+
const endpoint = getRedisEndpoint();
|
|
206
|
+
|
|
207
|
+
assert.ok(
|
|
208
|
+
!endpoint.includes("hunter2"),
|
|
209
|
+
`unparseable REDIS_URL leaked its password: ${endpoint}`,
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
});
|
package/src/utils.js
CHANGED
|
@@ -217,3 +217,44 @@ export const memoize = (fn, ttl = 0) => {
|
|
|
217
217
|
return value;
|
|
218
218
|
};
|
|
219
219
|
};
|
|
220
|
+
|
|
221
|
+
/** Placeholder written over a credential segment. No brackets — the URL
|
|
222
|
+
* serializer would percent-encode them into `%5BREDACTED%5D`. */
|
|
223
|
+
const REDACTED_CREDENTIAL = "REDACTED";
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Strips credentials from a URL so it is safe to log or return from an
|
|
227
|
+
* unauthenticated endpoint.
|
|
228
|
+
*
|
|
229
|
+
* Connection strings reach places the process cannot control: `/api/health`
|
|
230
|
+
* has no auth and echoes the message from a failed probe, and startup logs end
|
|
231
|
+
* up in a log aggregator. `redis://default:hunter2@cache.internal:6379` in any
|
|
232
|
+
* of those is a credential disclosure, so nothing that embeds a URL in a
|
|
233
|
+
* message should skip this.
|
|
234
|
+
*
|
|
235
|
+
* Parseable URLs are rewritten field by field. Anything else is run through a
|
|
236
|
+
* regex that strips the userinfo segment of a URL-shaped string. A string that
|
|
237
|
+
* matches neither has no recognisable credential segment and is returned
|
|
238
|
+
* unchanged.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} url
|
|
241
|
+
* @returns {string} The URL with any username/password replaced
|
|
242
|
+
*/
|
|
243
|
+
export const redactUrl = (url) => {
|
|
244
|
+
if (typeof url !== "string" || url.length === 0) return url;
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const parsed = new URL(url);
|
|
248
|
+
|
|
249
|
+
if (!parsed.username && !parsed.password) return url;
|
|
250
|
+
|
|
251
|
+
parsed.username = parsed.username ? REDACTED_CREDENTIAL : "";
|
|
252
|
+
parsed.password = parsed.password ? REDACTED_CREDENTIAL : "";
|
|
253
|
+
return parsed.toString();
|
|
254
|
+
} catch {
|
|
255
|
+
return url.replace(
|
|
256
|
+
/^([a-z][a-z0-9+.-]*:\/\/)[^/?#@]*@/i,
|
|
257
|
+
`$1${REDACTED_CREDENTIAL}@`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
};
|
package/src/utils.test.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
memoize,
|
|
9
9
|
normalizeErrorMessage,
|
|
10
10
|
randomString,
|
|
11
|
+
redactUrl,
|
|
11
12
|
retryAsync,
|
|
12
13
|
sanitizeId,
|
|
13
14
|
sleep,
|
|
@@ -191,3 +192,50 @@ test("Utils Module", async (t) => {
|
|
|
191
192
|
}, 50);
|
|
192
193
|
});
|
|
193
194
|
});
|
|
195
|
+
|
|
196
|
+
test("redactUrl", async (t) => {
|
|
197
|
+
await t.test("strips username and password from a parseable URL", () => {
|
|
198
|
+
const result = redactUrl("redis://default:hunter2@cache.internal:6379");
|
|
199
|
+
|
|
200
|
+
assert(!result.includes("hunter2"), `password leaked: ${result}`);
|
|
201
|
+
assert(!result.includes("default"), `username leaked: ${result}`);
|
|
202
|
+
assert(result.includes("cache.internal"));
|
|
203
|
+
assert(result.includes("6379"));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await t.test("strips a password-only credential", () => {
|
|
207
|
+
const result = redactUrl("redis://:hunter2@cache.internal:6379");
|
|
208
|
+
|
|
209
|
+
assert(!result.includes("hunter2"), `password leaked: ${result}`);
|
|
210
|
+
assert(result.includes("cache.internal"));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await t.test("strips credentials from a postgres URL", () => {
|
|
214
|
+
const result = redactUrl(
|
|
215
|
+
"postgresql://app:s3cret@db.internal:5432/app?schema=public",
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
assert(!result.includes("s3cret"), `password leaked: ${result}`);
|
|
219
|
+
assert(result.includes("db.internal"));
|
|
220
|
+
assert(result.includes("schema=public"));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await t.test("redacts the userinfo of a URL it cannot parse", () => {
|
|
224
|
+
const result = redactUrl("redis://user:hunter2@cache internal:6379");
|
|
225
|
+
|
|
226
|
+
assert(!result.includes("hunter2"), `password leaked: ${result}`);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await t.test("leaves a credential-free URL untouched", () => {
|
|
230
|
+
const url = "redis://cache.internal:6379";
|
|
231
|
+
|
|
232
|
+
assert(redactUrl(url) === url);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await t.test("passes through non-URL and empty values", () => {
|
|
236
|
+
assert(redactUrl("not-a-url") === "not-a-url");
|
|
237
|
+
assert(redactUrl("") === "");
|
|
238
|
+
assert(redactUrl(undefined) === undefined);
|
|
239
|
+
assert(redactUrl(null) === null);
|
|
240
|
+
});
|
|
241
|
+
});
|