@lotargo/memory_plugin 1.5.3 → 1.6.0

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.
@@ -26,15 +26,24 @@ function openBrowser(url) {
26
26
 
27
27
  // Starts a temporary loopback HTTP server to receive the OAuth callback.
28
28
  // Turso redirects the browser back to the root path: /?jwt=<JWT>&username=<USERNAME>
29
- export function startAuthLoopbackServer(port = 48900) {
29
+ // `expectedState` protects against OAuth CSRF: the callback is only accepted
30
+ // when it echoes the random `state` value that was embedded in the login URL.
31
+ export function startAuthLoopbackServer(port = 48900, expectedState = null) {
30
32
  return new Promise((resolve, reject) => {
31
33
  const server = http.createServer((req, res) => {
32
34
  const url = new URL(req.url, `http://${req.headers.host}`);
33
35
  const token = url.searchParams.get("jwt") || url.searchParams.get("token");
34
36
  const username = url.searchParams.get("username");
35
37
  const error = url.searchParams.get("error");
38
+ const receivedState = url.searchParams.get("state");
36
39
 
37
40
  if (token) {
41
+ if (expectedState && receivedState !== expectedState) {
42
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
43
+ res.end("Invalid state parameter");
44
+ server.close(() => reject(new Error("OAuth callback rejected: state parameter mismatch (possible CSRF attempt).")));
45
+ return;
46
+ }
38
47
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
39
48
  res.end(`
40
49
  <!DOCTYPE html>
@@ -379,9 +388,9 @@ export async function loginToCloud({
379
388
  let received;
380
389
  if (simulated && simulatedParams) {
381
390
  received = await new Promise((resolve, reject) => {
382
- const serverPromise = startAuthLoopbackServer(customPort);
391
+ const serverPromise = startAuthLoopbackServer(customPort, state);
383
392
  const req = http.request(
384
- `http://127.0.0.1:${customPort}/?jwt=${encodeURIComponent(simulatedParams.jwt)}&username=${encodeURIComponent(simulatedParams.username)}`,
393
+ `http://127.0.0.1:${customPort}/?jwt=${encodeURIComponent(simulatedParams.jwt)}&username=${encodeURIComponent(simulatedParams.username)}&state=${encodeURIComponent(state)}`,
385
394
  { method: "GET" },
386
395
  (res) => {
387
396
  res.resume();
@@ -393,7 +402,7 @@ export async function loginToCloud({
393
402
  });
394
403
  } else {
395
404
  openBrowser(loginUrl);
396
- received = await startAuthLoopbackServer(customPort);
405
+ received = await startAuthLoopbackServer(customPort, state);
397
406
  }
398
407
 
399
408
  const { token, username } = received;
@@ -1,21 +1,38 @@
1
1
  import { readFileSync, writeFileSync, existsSync, readdirSync, rmSync, statSync } from "node:fs";
2
- import { gzipSync, gunzipSync } from "node:zlib";
3
- import { join, resolve } from "node:path";
2
+ import { gzipSync } from "node:zlib";
3
+ import { join } from "node:path";
4
4
  import { getDatabase, BLOBS_DIR } from "../db/database.js";
5
5
  import { readBlob, saveBlob } from "../storage/blob_store.js";
6
6
  import { ensureExportsDir } from "../ingest/exporter.js";
7
+ import { realResolve, isWithin } from "../security/path_guard.js";
8
+ import { toVectorBytes } from "../retrieval/retriever.js";
9
+ import { safeGunzip } from "../storage/blob_store.js";
10
+
11
+ const ALLOWED_SNAPSHOT_DIRS = new Set();
12
+
13
+ export function registerSnapshotDir(dir) {
14
+ ALLOWED_SNAPSHOT_DIRS.add(realResolve(dir));
15
+ }
7
16
 
8
17
  export function validateSnapshotPath(pathStr, isExport = false) {
9
18
  if (typeof pathStr !== "string" || !pathStr.trim()) {
10
19
  throw new Error("Snapshot path cannot be empty.");
11
20
  }
12
- const resolved = resolve(pathStr.trim());
21
+ // realResolve follows symlinks/junctions, so a link inside the exports dir
22
+ // cannot be used to escape the allowlist.
23
+ const resolved = realResolve(pathStr.trim());
13
24
  if (!resolved.endsWith(".json") && !resolved.endsWith(".json.gz")) {
14
25
  throw new Error(`Invalid snapshot file extension for path '${pathStr}'. Path must end with .json or .json.gz`);
15
26
  }
16
27
  if (!isExport && !existsSync(resolved)) {
17
28
  throw new Error(`Snapshot file not found: ${pathStr}`);
18
29
  }
30
+ if (ALLOWED_SNAPSHOT_DIRS.size > 0) {
31
+ const allowed = [...ALLOWED_SNAPSHOT_DIRS].some((dir) => isWithin(resolved, dir));
32
+ if (!allowed) {
33
+ throw new Error(`Snapshot path '${pathStr}' is outside the allowed directories.`);
34
+ }
35
+ }
19
36
  return resolved;
20
37
  }
21
38
 
@@ -60,9 +77,9 @@ export async function exportSnapshot({ customDb = null, customBlobDir = BLOBS_DI
60
77
 
61
78
  const microChunks = rawMicroChunks.map((mc) => {
62
79
  let vecBase64 = "";
63
- if (mc.vector) {
64
- const buf = Buffer.isBuffer(mc.vector) ? mc.vector : Buffer.from(mc.vector);
65
- vecBase64 = buf.toString("base64");
80
+ const vecBytes = toVectorBytes(mc.vector);
81
+ if (vecBytes && vecBytes.byteLength) {
82
+ vecBase64 = Buffer.from(vecBytes.buffer, vecBytes.byteOffset, vecBytes.byteLength).toString("base64");
66
83
  }
67
84
  return {
68
85
  ...mc,
@@ -113,7 +130,7 @@ export async function importSnapshot({ customDb = null, customBlobDir = BLOBS_DI
113
130
  const validPath = validateSnapshotPath(snapshotPathOrData, false);
114
131
  const raw = readFileSync(validPath);
115
132
  if (validPath.endsWith(".gz")) {
116
- const decompressed = gunzipSync(raw);
133
+ const decompressed = safeGunzip(raw);
117
134
  snapshot = JSON.parse(decompressed.toString("utf-8"));
118
135
  } else {
119
136
  snapshot = JSON.parse(raw.toString("utf-8"));