@summerscar/dsh-mnemon-sync 0.1.7 → 0.1.9

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.
Files changed (2) hide show
  1. package/index.js +83 -17
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -40,10 +40,10 @@ const API_PREFIX = '/api/mnemon-sync'
40
40
  const ARCHIVE_PREFIX = 'mnemon-sync-'
41
41
 
42
42
  export const name = 'dsh-mnemon-sync'
43
- // `mnemonMemory` is optional on purpose: the plugin still backs up on a profile
44
- // where dsh-mnemon is absent or too old to accept Sources, it just cannot offer
45
- // its page there.
46
- export const inject = ['webServer']
43
+ // `connection` gates every HTTP route below. `mnemonMemory` stays optional on
44
+ // purpose: the plugin still backs up on a profile where dsh-mnemon is absent
45
+ // or too old to accept Sources, it just cannot offer its page there.
46
+ export const inject = ['webServer', 'connection']
47
47
 
48
48
  /**
49
49
  * `MNEMON_DATA_DIR` names the Mnemon home; the memory store itself lives in
@@ -201,10 +201,45 @@ export function apply(ctx, config = {}) {
201
201
  // page use its own type instead of borrowing `memory-spaces`, which is the
202
202
  // only way its tab can highlight independently.
203
203
 
204
+ /**
205
+ * Admit only requests the Host's connection service vouches for.
206
+ *
207
+ * These routes read credentials, trigger uploads and can restore files onto
208
+ * disk, so an unauthenticated local request must never reach them. Follow
209
+ * the current `requestRejection(req)` contract (undefined = admit, or an
210
+ * HTTP status); older Hosts exposing only `authorizeIndex` still work.
211
+ * If neither is available, reject rather than falling back to Host/Origin
212
+ * or loopback checks — a missing guard is not a reason to open the route.
213
+ */
214
+ const rejected = (req, res) => {
215
+ const connection = typeof ctx.get === 'function' ? ctx.get('connection') : undefined
216
+ if (connection === undefined || connection === null) {
217
+ res.statusCode = 401
218
+ res.end()
219
+ return true
220
+ }
221
+ if (typeof connection.requestRejection === 'function') {
222
+ const rejection = connection.requestRejection(req)
223
+ if (rejection === undefined) return false
224
+ res.statusCode = rejection
225
+ res.end()
226
+ return true
227
+ }
228
+ if (typeof connection.authorizeIndex === 'function') {
229
+ // `authorizeIndex` answers the response itself when it rejects; its
230
+ // boolean says which way it went.
231
+ return connection.authorizeIndex(req, res) === false
232
+ }
233
+ res.statusCode = 401
234
+ res.end()
235
+ return true
236
+ }
237
+
204
238
  ctx.effect(() => ctx.webServer.register({
205
239
  kind: 'prefix',
206
240
  path: API_PREFIX,
207
241
  handler: (req, res) => {
242
+ if (rejected(req, res)) return
208
243
  void handle(req, res).catch((error) => {
209
244
  sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
210
245
  })
@@ -452,14 +487,21 @@ export function apply(ctx, config = {}) {
452
487
  }))
453
488
  const restorable = requested.filter((component) => present.includes(component))
454
489
 
455
- // Plan first, so a dry run and a real run describe the same work.
490
+ // Plan first, so a dry run and a real run describe the same work. Entries
491
+ // whose path does not resolve into a component directory (a foreign file
492
+ // smuggled into the archive) are rejected outright rather than skipped:
493
+ // a listing that silently drops entries would hide what a restore does.
456
494
  const plan = []
457
495
  for (const component of restorable) {
458
496
  for (const [relative, content] of files.entries()) {
459
497
  if (relative === 'manifest.json') continue
460
498
  const director = componentDirectoryOf(relative)
461
499
  if (director !== component) continue
462
- plan.push({ component, relative, destination: destinationFor(home, relative), bytes: content.length })
500
+ const destination = destinationFor(home, relative)
501
+ if (destination === undefined) {
502
+ throw new Error(`archive entry cannot be restored safely: ${relative}`)
503
+ }
504
+ plan.push({ component, relative, destination, bytes: content.length })
463
505
  }
464
506
  }
465
507
 
@@ -559,20 +601,44 @@ function componentDirectoryOf(relative) {
559
601
  }
560
602
 
561
603
  /**
562
- * Where one archive path lands in the Mnemon home.
604
+ * Archive payload directories that a restore may write, mapped to the live
605
+ * directories they land in. `state/` is deliberately absent: it holds this
606
+ * plugin's credentials and dsh-mnemon's machine identity, and no backup
607
+ * component backs it up.
608
+ */
609
+ const RESTORE_DIRECTORIES = {
610
+ data: 'data',
611
+ documents: 'documents',
612
+ runtime: 'runtime',
613
+ }
614
+
615
+ /**
616
+ * Where one archive path lands in the Mnemon home, or `undefined` when the
617
+ * path is not part of a restorable component.
563
618
  *
564
- * `payload/` is dropped and the component directories are the same names the
565
- * store itself uses, so a restored tree is byte-identical in layout to a live
566
- * one. The path is resolved and checked so a malformed archive cannot walk out
567
- * of the home directory.
619
+ * The check is deliberately stricter than "stays inside the home": a restore
620
+ * must only ever touch the three component directories (`data/`, `documents/`,
621
+ * `runtime/`). Everything else in the home — `state/` with this plugin's own
622
+ * credentials and dsh-mnemon's machine identity, `skills/`, the archive
623
+ * workroot — must be unreachable from an archive, because the archive is
624
+ * untrusted data. A zip entry like `payload/data/../state/mnemon-sync.json`
625
+ * resolves inside the home yet would overwrite the stored credentials, so the
626
+ * first path segment after `payload/` is matched exactly against the three
627
+ * component names and no `..` or nested separator survives.
568
628
  */
569
629
  function destinationFor(home, relative) {
570
- const stripped = relative.replace(/^payload\//u, '')
571
- const destination = resolve(home, stripped)
572
- const root = resolve(home)
573
- if (destination !== root && !destination.startsWith(`${root}/`)) {
574
- throw new Error(`archive entry escapes the Mnemon home: ${relative}`)
575
- }
630
+ if (!relative.startsWith('payload/')) return undefined
631
+ const stripped = relative.slice('payload/'.length)
632
+ const separator = stripped.indexOf('/')
633
+ if (separator <= 0) return undefined
634
+ const topDirectory = stripped.slice(0, separator)
635
+ const componentDirectory = RESTORE_DIRECTORIES[topDirectory]
636
+ if (componentDirectory === undefined) return undefined
637
+ const rest = stripped.slice(separator + 1)
638
+ if (rest === '' || rest.includes('\0') || rest.split('/').includes('..')) return undefined
639
+ const destination = resolve(home, componentDirectory, ...rest.split('/'))
640
+ const root = resolve(home, componentDirectory)
641
+ if (!destination.startsWith(`${root}/`)) return undefined
576
642
  return destination
577
643
  }
578
644
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@summerscar/dsh-mnemon-sync",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Back up and restore dsh-mnemon memory components (memory spaces, project documents, runtime memory) to S3-compatible storage or WebDAV.",
5
5
  "type": "module",
6
6
  "main": "index.js",