mikser-io-drive 11.4.0 → 11.6.2

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
@@ -209,6 +209,110 @@ fall straight through.
209
209
  The `<base>/<name>` surface is unchanged and still answers on every host. This
210
210
  adds a way in; it does not move the old one.
211
211
 
212
+ ### The root authenticates, and why that keeps your password
213
+
214
+ `OPTIONS /` on the drive host answers **401 with a Basic challenge** unless the
215
+ request already authenticates. That is not a hardening pass; it is what keeps a
216
+ Windows client's stored credential usable.
217
+
218
+ The redirector builds its session against the server root before it touches the
219
+ path. An anonymous `200` there told it the session needed nothing, so
220
+ credentials were attached later and reactively, when the `PROPFIND` came back
221
+ 401. That attachment did not survive the session: after a reboot, or after
222
+ WebClient's idle teardown, the redirector asked again, got the same clean 200,
223
+ reached the same conclusion, and at the PROPFIND prompted a human instead of
224
+ replaying Credential Manager — with the credential sitting in `cmdkey` the
225
+ whole time, and "Remember my credentials" making no difference. Apache
226
+ `mod_dav` inside `Require valid-user` returns 401 on OPTIONS, and reconnects
227
+ there are silent.
228
+
229
+ Nothing was being protected by the anonymous answer: a `200` says "a WebDAV
230
+ server is here", and a `401` says the same thing plus a realm. The endpoint
231
+ names — the part that discloses something — were gated then and are gated now.
232
+
233
+ If you have a health check or monitor pointed at `/`, keep the old answer:
234
+
235
+ ```js
236
+ drive({ host: 'drive.example.com', anonymousDiscovery: true, … })
237
+ ```
238
+
239
+ That relaxes discovery only. `PROPFIND /` still requires credentials.
240
+
241
+ ### The Basic challenge follows your auth configuration
242
+
243
+ The drive writes its own `WWW-Authenticate: Basic` header rather than
244
+ delegating to the verifier — a composite verifier challenges with whichever
245
+ member can be satisfied interactively, and where OAuth discovery is configured
246
+ that is Bearer, which Explorer and Finder do not speak.
247
+
248
+ The `charset="UTF-8"` parameter is not a decision this package makes. It reads
249
+ `basicCharset` from the auth plugin, so a deployment sets it once:
250
+
251
+ ```js
252
+ auth({ basicCharset: false }) // on by default
253
+ ```
254
+
255
+ It briefly defaulted off on a theory about the Windows redirector that did not
256
+ survive measurement. See the auth package's README.
257
+
258
+ ### Compliance classes at the root
259
+
260
+ The root advertises `DAV: 1, 2, 3` — the same classes as the endpoints
261
+ beneath it — and that is deliberately more than this one resource can do on
262
+ its own.
263
+
264
+ RFC 4918 §18 defines the classes per **resource**, and §18.2 wants LOCK from a
265
+ class 2 resource. The root is a read-only listing: its `Allow` says
266
+ `OPTIONS, PROPFIND` and means it, and §18.3 spells out the strictly honest
267
+ header for that situation, `DAV: 1, 3`.
268
+
269
+ It claims 2 anyway because the clients this package exists for read capability
270
+ at the **root** and decide the whole mount from it — Finder refuses a
271
+ read-write mount without class 2, which is also why `locks: 'disallow'` carries
272
+ a warning. Understating there costs a working mount; overstating costs a 405 to
273
+ a LOCK nothing sends, since clients lock the files they edit and those live
274
+ under the endpoints, which support locking for real. `Allow` stays truthful, so
275
+ a client that asks what the root itself permits gets the right answer.
276
+
277
+ ### When Windows cannot keep the mount
278
+
279
+ Everything above gets Explorer to mount the drive. Keeping it mounted across a
280
+ restart is a separate problem, and on a machine that has to run unattended it
281
+ is not solvable from this side.
282
+
283
+ The redirector reads saved credentials from the **Domain Password** store.
284
+ Basic credentials cannot be persisted there — [KB 2673544][kb] documents this
285
+ as by design, because WinHTTP cannot retrieve a saved Basic credential — so
286
+ "remember my credentials" quietly stores nothing the reconnect can use. It
287
+ looks like a server fault and is not one. Measured against a live endpoint,
288
+ a reconnect sent four requests, **none** of them carrying an `Authorization`
289
+ header, and gave up; the credential was sitting in Credential Manager the whole
290
+ time, in the `Generic` store that `cmdkey /generic:` writes and the redirector
291
+ never reads. Apache `mod_dav` on the same host, with the same credential,
292
+ failed identically — which is the useful part of the measurement.
293
+
294
+ So for a machine that exports documents on its own, do not mount the drive.
295
+ Have the application write to a local folder and push that folder up:
296
+
297
+ ```
298
+ wscript rclone-outbox.vbs "C:\SkinCheck" SkinCheck:Janus 120
299
+ ```
300
+
301
+ [`clients/windows/rclone-outbox.vbs`](clients/windows/rclone-outbox.vbs) is
302
+ that loop — an [rclone][rclone] `move` on an interval, run by `wscript.exe` so
303
+ no console window appears, and started by a shortcut in `shell:startup` so it
304
+ needs no elevation and no Scheduled Task. rclone authenticates from its own
305
+ config on every request and has no reconnect to lose, which is the whole reason
306
+ it works where the mount does not.
307
+
308
+ This trades a drive letter for a folder, and it is worth being clear about what
309
+ that costs: nobody browses the server from the machine any more. In exchange an
310
+ export can no longer fail because a mount dropped, and a server that is
311
+ unreachable delays the upload instead of losing the document.
312
+
313
+ [kb]: https://support.microsoft.com/help/2673544
314
+ [rclone]: https://rclone.org/webdav/
315
+
212
316
  ## What the folder is called
213
317
 
214
318
  The endpoint key is the name, and the mount reports it as `displayname`:
@@ -0,0 +1,79 @@
1
+ ' rclone-outbox.vbs - hidden, looping uploader for a mikser-io-drive endpoint.
2
+ '
3
+ ' Windows cannot mount this drive unattended. The WebDAV redirector reads its
4
+ ' credentials from the Domain Password store and refuses to persist Basic
5
+ ' credentials there at all (KB 2673544), so a mapped drive re-prompts after
6
+ ' every restart and an export silently lands on a dead letter. Measured against
7
+ ' a live endpoint: four redirector requests during a reconnect, none of them
8
+ ' carrying an Authorization header, while rclone authenticated on its first.
9
+ '
10
+ ' So the export never touches the network. The exporting application writes to
11
+ ' a local folder and this moves the folder's contents up, which means an export
12
+ ' cannot fail because a mount dropped, and an unreachable server only delays
13
+ ' the upload instead of losing the file.
14
+ '
15
+ ' This is a .vbs and not a .cmd because wscript.exe runs with no console at
16
+ ' all. A .cmd on a two-minute timer flashes a window on the desktop every two
17
+ ' minutes, and on a shared machine someone eventually turns it off. The other
18
+ ' way to run a console tool quietly is a Scheduled Task, which needs elevation
19
+ ' to register in the root task folder - this needs none: put a shortcut to it
20
+ ' in shell:startup and it begins with the session.
21
+ '
22
+ ' Usage:
23
+ ' wscript rclone-outbox.vbs <outbox folder> <rclone remote> [interval seconds]
24
+ '
25
+ ' For example, against an endpoint declared as `SkinCheck: { folder: ... }`:
26
+ ' wscript rclone-outbox.vbs "C:\SkinCheck" SkinCheck:Janus 120
27
+
28
+ Option Explicit
29
+
30
+ Dim shell, arguments, outbox, remote, interval, rclone, logFile, command
31
+
32
+ Set shell = CreateObject("WScript.Shell")
33
+ Set arguments = WScript.Arguments
34
+
35
+ If arguments.Count < 2 Then
36
+ WScript.Echo "usage: wscript rclone-outbox.vbs <outbox folder> <remote> [interval seconds]"
37
+ WScript.Quit 1
38
+ End If
39
+
40
+ outbox = arguments(0)
41
+ remote = arguments(1)
42
+ If arguments.Count > 2 Then
43
+ interval = CLng(arguments(2))
44
+ Else
45
+ interval = 120
46
+ End If
47
+
48
+ rclone = shell.ExpandEnvironmentStrings("%LOCALAPPDATA%\rclone\rclone.exe")
49
+ logFile = shell.ExpandEnvironmentStrings("%LOCALAPPDATA%\rclone\outbox.log")
50
+
51
+ ' move, not copy: the server is the system of record, the same as the mapped
52
+ ' drive it replaces, and it keeps the exported documents off a shared desktop.
53
+ ' It also gives whoever works at the machine a correct indicator that needs no
54
+ ' explaining - an empty outbox means everything is up, and a failing upload
55
+ ' makes files visibly pile up instead of failing where nobody looks.
56
+ '
57
+ ' --min-age keeps a file that is still being written out of the transfer. The
58
+ ' exporting application is long finished with it by the time it ages in, and a
59
+ ' half-written document uploaded and then deleted locally is unrecoverable.
60
+ ' --ignore-times because the server reports no modification-time support
61
+ ' (rclone prints a Precision of 100 years for it), which leaves size as the
62
+ ' only thing rclone can compare. A re-export under the same name and the same
63
+ ' size - a corrected document for the same patient and date - would otherwise
64
+ ' be treated as already uploaded, skipped, and deleted locally. Measured: a
65
+ ' 3.773 MiB export was removed with "There was nothing to transfer".
66
+ '
67
+ ' It costs nothing here. move empties the outbox, so a document is offered
68
+ ' once and uploaded once; this only removes a skip decision that cannot be
69
+ ' made safely.
70
+ command = """" & rclone & """ move """ & outbox & """ " & remote & _
71
+ " --min-age 1m --ignore-times --transfers 2" & _
72
+ " --log-level INFO --log-file """ & logFile & """"
73
+
74
+ Do
75
+ ' 0 hides the window; True waits for the run to finish, so a slow upload
76
+ ' delays the next pass instead of overlapping with it.
77
+ shell.Run command, 0, True
78
+ WScript.Sleep interval * 1000
79
+ Loop
package/index.js CHANGED
@@ -68,6 +68,22 @@ export const writeCapability = (name) => `drive:${name}:write`
68
68
  * handler (server.js), so an endpoint at `/content` would silently shadow a
69
69
  * real page at /content/ in the built site.
70
70
  */
71
+ // Does this deployment's auth want charset="UTF-8" on the Basic challenge?
72
+ //
73
+ // Asked of the auth plugin rather than configured here, so the decision is
74
+ // made once per deployment. mikser-io-auth exposes `basicCharset` and
75
+ // defaults it ON; anything else — a bare static token, no auth at all — has
76
+ // no opinion, and falls back to the same default rather than to silence.
77
+ //
78
+ // Not delegated to the verifier's own `challenge()`, which would be the
79
+ // tidier shape: a composite verifier challenges with whichever member can be
80
+ // satisfied interactively, and where OAuth discovery is configured that is
81
+ // Bearer. Explorer and Finder speak Basic and nothing else, so the scheme
82
+ // here is not negotiable — only the parameter is.
83
+ function basicCharsetOf(auth) {
84
+ return auth?.basicCharset ?? true
85
+ }
86
+
71
87
  export function drive(options = {}) {
72
88
  const {
73
89
  base = '/drive',
@@ -87,6 +103,10 @@ export function drive(options = {}) {
87
103
  host,
88
104
  auth,
89
105
  realm = 'mikser',
106
+ // `charset="UTF-8"` on the Basic challenge, RFC 7617 §2.1, is not a
107
+ // knob of this package's own: it follows whatever the auth plugin was
108
+ // configured with, so a deployment decides it once. See
109
+ // basicChallenge in mikser-io for why the default is on.
90
110
  // Nephele defaults both to 'meta-files', which writes sidecars INTO
91
111
  // the folder being served. The sidecars are filtered out of the
92
112
  // catalog now (see registerJunk above), so the remaining reason to
@@ -123,6 +143,14 @@ export function drive(options = {}) {
123
143
  // read-write mount without it.
124
144
  properties = 'emulate',
125
145
  locks = 'emulate',
146
+ // Answer OPTIONS at the host root without authentication.
147
+ //
148
+ // Off, because an anonymous 200 there costs Windows its stored
149
+ // credential: the redirector builds its session at the root, is told
150
+ // it needs nothing, and re-prompts after every reboot and every idle
151
+ // teardown. See lib/root-listing.js. Turn it on for a deployment
152
+ // that wants an unauthenticated probe at `/`.
153
+ anonymousDiscovery = false,
126
154
  } = options
127
155
 
128
156
  return ({ runtime, onLoaded, useLogger }) => {
@@ -217,6 +245,7 @@ export function drive(options = {}) {
217
245
  // ReadOnlyPlugin refuses every mutation regardless.
218
246
  writeCapability: readOnly ? null : writeCapability(name),
219
247
  realm,
248
+ charset: basicCharsetOf(auth),
220
249
  logger,
221
250
  })
222
251
 
@@ -401,8 +430,10 @@ export function drive(options = {}) {
401
430
  allowRemote: true,
402
431
  trustLoopback: !auth,
403
432
  realm,
433
+ charset: basicCharsetOf(auth),
404
434
  },
405
435
  logger,
436
+ anonymousDiscovery,
406
437
  })(req, res, next)
407
438
  : next()))
408
439
 
@@ -1,5 +1,5 @@
1
1
  import { UnauthorizedError, ForbiddenError } from 'nephele'
2
- import { authorize } from 'mikser-io'
2
+ import { authorize, basicChallenge } from 'mikser-io'
3
3
  // The one implementation of "does this principal hold it", including the
4
4
  // `*` wildcard — checked here rather than re-derived, because two gates in
5
5
  // this file and one in mikser-io-auth each had their own `.includes` and a
@@ -39,11 +39,15 @@ export class MikserAuthenticator {
39
39
  #capability
40
40
  #writeCapability
41
41
  #realm
42
+ // Whether the Basic challenge carries charset="UTF-8". Follows the
43
+ // deployment's auth configuration rather than being decided here — see
44
+ // basicChallenge in mikser-io.
45
+ #charset
42
46
  #logger
43
47
 
44
48
  constructor({
45
49
  verifier, trustLoopback = false, allowRemote = false,
46
- capability, writeCapability, realm = 'mikser', logger,
50
+ capability, writeCapability, realm = 'mikser', charset = false, logger,
47
51
  } = {}) {
48
52
  this.#verifier = verifier
49
53
  this.#trustLoopback = trustLoopback
@@ -51,6 +55,7 @@ export class MikserAuthenticator {
51
55
  this.#capability = capability
52
56
  this.#writeCapability = writeCapability
53
57
  this.#realm = realm
58
+ this.#charset = charset
54
59
  this.#logger = logger
55
60
  }
56
61
 
@@ -72,7 +77,8 @@ export class MikserAuthenticator {
72
77
  // challenge has to be set here — and it must offer Basic, because
73
78
  // that is the only scheme Finder and Explorer speak.
74
79
  if (outcome.status === 401) {
75
- response.set('WWW-Authenticate', `Basic realm="${this.#realm}", charset="UTF-8"`)
80
+ response.set('WWW-Authenticate',
81
+ basicChallenge({ realm: this.#realm, charset: this.#charset }))
76
82
  throw new UnauthorizedError(outcome.error)
77
83
  }
78
84
  // 403: the credential was never the problem — the caller's origin
@@ -16,7 +16,7 @@
16
16
  // server with its own authenticator — so per-endpoint auth stays exactly where
17
17
  // it was rather than hanging off a shared root.
18
18
 
19
- import { authorize, hasCapability } from 'mikser-io'
19
+ import { authorize, hasCapability, basicChallenge } from 'mikser-io'
20
20
 
21
21
  const escape = (text) => String(text)
22
22
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
@@ -41,31 +41,83 @@ const collection = (href, displayName) =>
41
41
  * @param {string} input.rootName what the root collection calls itself
42
42
  * @param {object} input.auth { verifier, allowRemote, trustLoopback, realm }
43
43
  * @param {object} input.logger
44
+ * @param {boolean} [input.anonymousDiscovery] answer OPTIONS without auth
44
45
  */
45
- export function rootListing({ entries, rootName, auth, logger }) {
46
+ // The OPTIONS answer itself.
47
+ //
48
+ // `DAV: 1, 2, 3` — the same classes the endpoints advertise, and this is a
49
+ // deliberate compatibility choice against a literal reading of the spec, so
50
+ // it is worth being plain about.
51
+ //
52
+ // RFC 4918 §18 defines the classes per RESOURCE, not per server, so a root
53
+ // advertising less than its endpoints is not in itself incoherent. But §18.2
54
+ // is explicit that a class 2 resource MUST support LOCK, and this collection
55
+ // does not: its `Allow` below says OPTIONS and PROPFIND and means it. §18.3
56
+ // even spells out the honest header for exactly this resource — `DAV: 1, 3`,
57
+ // "all the requirements in this specification except possibly those that
58
+ // involve locking support".
59
+ //
60
+ // It advertises 2 anyway, because the clients this package exists to serve
61
+ // read capability at the ROOT and decide the whole mount from it: Finder
62
+ // refuses a read-write mount without class 2 (the same reason `locks:
63
+ // 'disallow'` carries a warning in index.js), and the redirector was
64
+ // observed treating the tree as the root described it. Understating here
65
+ // costs a working mount; overstating costs a 405 to a LOCK nothing sends,
66
+ // because clients lock the files they edit and those live under the
67
+ // endpoints, which support it for real.
68
+ //
69
+ // `Allow` stays truthful, so a client that asks what this resource permits
70
+ // gets the right answer.
71
+ function respondOptions(res) {
72
+ res.set('DAV', '1, 2, 3')
73
+ res.set('MS-Author-Via', 'DAV')
74
+ res.set('Allow', 'OPTIONS, PROPFIND')
75
+ // No caching of a discovery answer — the same rule as the mounts, and for
76
+ // the same reason: a client that cached a refusal keeps it.
77
+ res.set('Cache-Control', 'no-cache')
78
+ return res.status(200).end()
79
+ }
80
+
81
+ export function rootListing({ entries, rootName, auth, logger, anonymousDiscovery = false }) {
46
82
  return async (req, res, next) => {
47
83
  // Only the root itself. Everything below is a real endpoint mount.
48
84
  if (req.path !== '/') return next()
49
85
 
50
- if (req.method === 'OPTIONS') {
51
- // Answered WITHOUT authentication, on purpose: this is discovery,
52
- // and it is what the redirector asks before it has credentials to
53
- // offer. It discloses that a WebDAV server is here and nothing
54
- // about what is in it — the listing below is the gated part.
55
- //
56
- // Class 1 only. This collection supports reading its own children
57
- // and nothing else; the endpoints inside it advertise their own
58
- // classes, including locking.
59
- res.set('DAV', '1')
60
- res.set('MS-Author-Via', 'DAV')
61
- res.set('Allow', 'OPTIONS, PROPFIND')
62
- // No caching of a discovery answer — the same rule as the mounts,
63
- // and for the same reason: a client that cached a refusal keeps it.
64
- res.set('Cache-Control', 'no-cache')
65
- return res.status(200).end()
66
- }
86
+ if (req.method !== 'OPTIONS' && req.method !== 'PROPFIND') return next()
67
87
 
68
- if (req.method !== 'PROPFIND') return next()
88
+ // Both methods authenticate, and OPTIONS doing so is the fix for a
89
+ // Windows credential lifecycle, not a tightening for its own sake.
90
+ //
91
+ // The redirector establishes its session against the SERVER ROOT
92
+ // before it touches the path — the reason this root exists at all.
93
+ // Answering that OPTIONS with an anonymous 200 told it the session
94
+ // needed nothing, so credentials were attached later and reactively,
95
+ // when the PROPFIND 401'd. That attachment did not survive the
96
+ // session: after a reboot, or after WebClient's idle teardown, the
97
+ // redirector asked again, got the same clean 200, concluded the same
98
+ // thing, and at the PROPFIND prompted a human instead of replaying
99
+ // Credential Manager. Measured on a live Windows 11 client: the
100
+ // credential was in cmdkey the whole time and never sent.
101
+ //
102
+ // Apache mod_dav with the DAV location inside `Require valid-user`
103
+ // 401s on OPTIONS, and reconnects there are silent. That comparison
104
+ // is what identified this.
105
+ //
106
+ // The disclosure argument the old comment made does not survive
107
+ // contact: an anonymous 200 says "a WebDAV server is here", and a 401
108
+ // says the same thing plus a realm. Neither says anything about the
109
+ // contents, so nothing was being protected — while the cost was a
110
+ // password prompt after every reboot.
111
+ //
112
+ // RFC 4918 does not forbid it. §8.1 points the other way if anything:
113
+ // "Servers MUST return authorization errors in preference to other
114
+ // errors." And no CORS preflight is at stake, which is the one case
115
+ // where challenging OPTIONS would be wrong — the drive's routes are
116
+ // registered `cors: false`.
117
+ //
118
+ // `anonymousDiscovery: true` restores the old behaviour for a
119
+ // deployment that wants an unauthenticated probe at the root.
120
+ if (req.method === 'OPTIONS' && anonymousDiscovery) return respondOptions(res)
69
121
 
70
122
  let outcome
71
123
  try {
@@ -76,7 +128,7 @@ export function rootListing({ entries, rootName, auth, logger }) {
76
128
  } catch (err) {
77
129
  logger?.error?.('drive: verifier threw at the root — %s', err.message)
78
130
  return res.status(401)
79
- .set('WWW-Authenticate', `Basic realm="${auth.realm}", charset="UTF-8"`)
131
+ .set('WWW-Authenticate', basicChallenge({ realm: auth.realm, charset: auth.charset }))
80
132
  .end()
81
133
  }
82
134
 
@@ -85,12 +137,17 @@ export function rootListing({ entries, rootName, auth, logger }) {
85
137
  // and without the challenge a DAV client never prompts.
86
138
  if (outcome.status === 401) {
87
139
  return res.status(401)
88
- .set('WWW-Authenticate', `Basic realm="${auth.realm}", charset="UTF-8"`)
140
+ .set('WWW-Authenticate', basicChallenge({ realm: auth.realm, charset: auth.charset }))
89
141
  .end()
90
142
  }
143
+ // Authenticated and not permitted. NEVER 401 here: a client that
144
+ // is told to authenticate again by credentials that already
145
+ // worked prompts forever.
91
146
  return res.status(403).end()
92
147
  }
93
148
 
149
+ if (req.method === 'OPTIONS') return respondOptions(res)
150
+
94
151
  // What this user may read. A `capability: null` entry is not
95
152
  // capability-scoped — no map configured, or a bare static token —
96
153
  // which passes, the same rule every other surface applies.
@@ -113,7 +170,7 @@ export function rootListing({ entries, rootName, auth, logger }) {
113
170
 
114
171
  res.status(207)
115
172
  .set('Content-Type', 'application/xml; charset=utf-8')
116
- .set('DAV', '1')
173
+ .set('DAV', '1, 2, 3')
117
174
  .send(`<?xml version="1.0" encoding="utf-8"?>\n`
118
175
  + `<D:multistatus xmlns:D="DAV:">\n${responses.join('\n')}\n</D:multistatus>\n`)
119
176
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-drive",
3
- "version": "11.4.0",
3
+ "version": "11.6.2",
4
4
  "description": "WebDAV for mikser-io. Exposes working-folder directories as WebDAV endpoints via Nephele, authenticated through mikser-io-auth's htpasswd identity — so content can be edited from Finder, Explorer or any DAV client and the build picks the change up.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -8,7 +8,7 @@
8
8
  "better-sqlite3": true
9
9
  },
10
10
  "scripts": {
11
- "test": "node --no-warnings --test --test-reporter=spec 'test/**/*.test.js'"
11
+ "test": "node --no-warnings --test --test-timeout=120000 --test-reporter=spec 'test/**/*.test.js'"
12
12
  },
13
13
  "repository": {
14
14
  "type": "git",
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "homepage": "https://github.com/almero-digital-marketing/mikser-io-drive#readme",
30
30
  "peerDependencies": {
31
- "mikser-io": "^11.7.0",
31
+ "mikser-io": "^11.11.1",
32
32
  "zod": "^4.0.0"
33
33
  },
34
34
  "dependencies": {
@@ -41,7 +41,7 @@
41
41
  "bcryptjs": "^3.0.0",
42
42
  "express": "^5.2.1",
43
43
  "globby": "^15.0.0",
44
- "mikser-io": "^11.7.0",
44
+ "mikser-io": "^11.11.1",
45
45
  "mikser-io-auth": "^11.0.1",
46
46
  "mikser-io-mcp": "^11.0.1",
47
47
  "webdav": "^5.10.0",
@@ -0,0 +1,102 @@
1
+ // The escape hatch for a deployment that wants an unauthenticated probe at
2
+ // the root.
3
+ //
4
+ // Challenging OPTIONS is the default because an anonymous 200 there costs a
5
+ // Windows client its stored credential — see lib/root-listing.js. But it is
6
+ // a change to who may touch `/`, and a deployment with a health check or a
7
+ // monitor pointed at the root should be able to keep the old answer rather
8
+ // than being told its monitoring is wrong.
9
+
10
+ import { describe, it, before, after } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+ import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises'
13
+ import { tmpdir } from 'node:os'
14
+ import path from 'node:path'
15
+ import bcrypt from 'bcryptjs'
16
+ import http from 'node:http'
17
+
18
+ import { runtime } from 'mikser-io'
19
+ import { auth } from 'mikser-io-auth'
20
+ import { drive } from '../index.js'
21
+
22
+ const HOST = 'anon.example.test'
23
+ let server, port, dir
24
+
25
+ // Raw http: the Host header is what selects the drive's domain, and fetch()
26
+ // drops it — `Host` is forbidden in undici.
27
+ function request(method, urlPath, headers = {}) {
28
+ return new Promise((resolve, reject) => {
29
+ const req = http.request(
30
+ { host: '127.0.0.1', port, path: urlPath, method, headers: { Host: HOST, ...headers } },
31
+ (res) => {
32
+ let body = ''
33
+ res.on('data', (chunk) => { body += chunk })
34
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }))
35
+ })
36
+ req.on('error', reject)
37
+ req.end()
38
+ })
39
+ }
40
+
41
+ before(async () => {
42
+ dir = await mkdtemp(path.join(tmpdir(), 'mikser-drive-anon-'))
43
+ for (const folder of ['documents', 'runtime']) {
44
+ await mkdir(path.join(dir, folder), { recursive: true })
45
+ }
46
+ await writeFile(path.join(dir, 'users.htpasswd'),
47
+ `alice:${bcrypt.hashSync('alice-pw', 10)}\n`)
48
+ await writeFile(path.join(dir, 'groups.htgroup'), 'editors: alice\n')
49
+
50
+ const { default: express } = await import('express')
51
+ const app = express()
52
+ runtime.options = { ...runtime.options, app, workingFolder: dir, runtimeFolder: path.join(dir, 'runtime') }
53
+ runtime.config = { ...runtime.config }
54
+ runtime.engine = { ...runtime.engine, logger: { info(){}, warn(){}, error(){}, debug(){}, trace(){}, fatal(){} } }
55
+
56
+ const identity = auth({ capabilities: { editors: ['drive:SkinCheck'] } })
57
+ const plugin = drive({
58
+ host: HOST,
59
+ auth: identity,
60
+ anonymousDiscovery: true,
61
+ endpoints: { SkinCheck: { folder: 'documents' } },
62
+ })
63
+
64
+ const load = [], loaded = []
65
+ const core = {
66
+ runtime,
67
+ onLoad: (cb) => load.push(cb),
68
+ onLoaded: (cb) => loaded.push(cb),
69
+ useLogger: () => ({ info(){}, warn(){}, error(){}, debug(){}, trace(){} }),
70
+ }
71
+ identity(core)
72
+ plugin(core)
73
+ for (const cb of load) await cb()
74
+ for (const cb of loaded) await cb()
75
+
76
+ server = await new Promise(resolve => { const s = app.listen(0, () => resolve(s)) })
77
+ port = server.address().port
78
+ })
79
+
80
+ after(async () => {
81
+ server?.closeAllConnections?.()
82
+ await new Promise(r => server?.close(r))
83
+ await rm(dir, { recursive: true, force: true })
84
+ })
85
+
86
+ describe('anonymousDiscovery: true', () => {
87
+ it('answers OPTIONS at the root without credentials', async () => {
88
+ const res = await request('OPTIONS', '/')
89
+ assert.equal(res.status, 200)
90
+ assert.match(res.headers.dav ?? '', /\b1\b/)
91
+ assert.match(res.headers.allow ?? '', /PROPFIND/)
92
+ })
93
+
94
+ it('still gates the listing, which is the part that discloses anything', async () => {
95
+ // The flag relaxes DISCOVERY, not access. An anonymous 200 says a
96
+ // WebDAV server is here; the endpoint names are what a caller has to
97
+ // earn, and that is unchanged.
98
+ const res = await request('PROPFIND', '/', { depth: '1' })
99
+ assert.equal(res.status, 401)
100
+ assert.match(res.headers['www-authenticate'] ?? '', /^Basic /)
101
+ })
102
+ })
@@ -124,11 +124,17 @@ before(async (t) => {
124
124
  + ' ],\n'
125
125
  + '})\n')
126
126
 
127
+ // `detached` so this gets its own process GROUP, which is what the
128
+ // teardown kills. mikser under --watch --server runs render workers, and
129
+ // they inherit the pipes below: signalling the parent alone can leave a
130
+ // worker holding the write end open, which keeps THIS process's read
131
+ // handles alive and the test run never ends. Killing the group takes the
132
+ // whole tree.
127
133
  server = spawn(process.execPath, [
128
134
  path.join(dir, 'node_modules/mikser-io/app.js'),
129
135
  '--working-folder', dir, '--output-folder', 'out', '--runtime-folder', 'runtime',
130
136
  '--watch', '--server', String(PORT), '--url', `http://127.0.0.1:${PORT}`,
131
- ], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] })
137
+ ], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
132
138
 
133
139
  // Kept so a server that never comes up says WHY. Throwing this away left
134
140
  // the failure as a bare ECONNREFUSED, which names the symptom and hides
@@ -155,6 +161,15 @@ before(async (t) => {
155
161
  await new Promise(r => setTimeout(r, 3000))
156
162
  })
157
163
 
164
+ // Signal the whole process GROUP, not just the child.
165
+ //
166
+ // `detached: true` above put the server in its own group, so a negative pid
167
+ // reaches it and everything it started. Falls back to the child alone if the
168
+ // group is already gone, which is the ordinary case and not an error.
169
+ function killTree(child, signal) {
170
+ try { process.kill(-child.pid, signal) } catch { try { child.kill(signal) } catch { /* already gone */ } }
171
+ }
172
+
158
173
  after(async () => {
159
174
  // WAIT for it to die, and escalate. `kill()` only sends the signal; the
160
175
  // child's stdout/stderr are pipes we hold, so until it actually exits
@@ -167,16 +182,30 @@ after(async () => {
167
182
  // close() does not come back.
168
183
  if (server && server.exitCode === null && server.signalCode === null) {
169
184
  const exited = new Promise(resolve => server.once('exit', resolve))
170
- server.kill()
185
+ killTree(server, 'SIGTERM')
171
186
  const inTime = await Promise.race([
172
187
  exited.then(() => true),
173
188
  new Promise(resolve => setTimeout(() => resolve(false), 5000)),
174
189
  ])
175
190
  if (!inTime) {
176
- server.kill('SIGKILL')
177
- await exited
191
+ killTree(server, 'SIGKILL')
192
+ // Bounded. `await exited` alone assumes the child is the only
193
+ // thing that can hold this up, and the whole failure being fixed
194
+ // here is that it is not.
195
+ await Promise.race([
196
+ exited,
197
+ new Promise(resolve => setTimeout(resolve, 5000)),
198
+ ])
178
199
  }
179
200
  }
201
+ // The pipes, explicitly, and this is the actual fix rather than belt and
202
+ // braces. A worker that inherited the write end keeps this side readable
203
+ // even once the server is gone, and a readable stream handle is enough to
204
+ // keep node alive: every test passes, nothing is left to run, and the
205
+ // process simply sits there. On CI that was fifteen minutes of silence
206
+ // ended by the job timeout and "Terminate orphan process", twice.
207
+ server?.stdout?.destroy()
208
+ server?.stderr?.destroy()
180
209
  if (dir) await rm(dir, { recursive: true, force: true })
181
210
  })
182
211
 
@@ -101,8 +101,11 @@ describe('auth', () => {
101
101
  it('challenges an anonymous request with Basic, so a DAV client prompts', async () => {
102
102
  const res = await dav('PROPFIND', '/drive/content/', { headers: { depth: '0' } })
103
103
  assert.equal(res.status, 401)
104
- assert.match(res.headers.get('www-authenticate'), /^Basic realm="mikser"/)
105
- assert.match(res.headers.get('www-authenticate'), /charset="UTF-8"/)
104
+ // Exact bytes: the drive follows the deployment's auth configuration
105
+ // rather than hardcoding the parameter, and this fixture leaves it at
106
+ // the default. See basicChallenge in mikser-io.
107
+ assert.equal(res.headers.get('www-authenticate'),
108
+ 'Basic realm="mikser", charset="UTF-8"')
106
109
  })
107
110
 
108
111
  it('refuses a wrong password', async () => {
@@ -0,0 +1,93 @@
1
+ // A caller who authenticated and is still not permitted must get 403, never
2
+ // 401 — at OPTIONS as well as at PROPFIND.
3
+ //
4
+ // This is the prompt loop from the other direction, and it is why OPTIONS
5
+ // now shares PROPFIND's authorization path rather than getting a challenge
6
+ // of its own: telling a client to authenticate again with credentials that
7
+ // just worked makes Explorer prompt forever. The distinction is the
8
+ // verifier's to make — `rejectionFor` is how it refines "presented and
9
+ // invalid" into "refreshing will not help" — so this drives the middleware
10
+ // directly with such a verifier, which is the only way to reach that branch.
11
+
12
+ import { describe, it } from 'node:test'
13
+ import assert from 'node:assert/strict'
14
+
15
+ import { rootListing } from '../lib/root-listing.js'
16
+
17
+ // The middleware only touches these.
18
+ function fakeResponse() {
19
+ const res = {
20
+ statusCode: null, headers: {}, ended: false,
21
+ status(code) { res.statusCode = code; return res },
22
+ set(name, value) { res.headers[name.toLowerCase()] = value; return res },
23
+ end() { res.ended = true; return res },
24
+ send() { res.ended = true; return res },
25
+ }
26
+ return res
27
+ }
28
+
29
+ const middleware = (verifier) => rootListing({
30
+ entries: [{ name: 'SkinCheck', displayName: 'SkinCheck', capability: 'drive:SkinCheck' }],
31
+ rootName: 'drive',
32
+ auth: { verifier, allowRemote: true, trustLoopback: false, realm: 'mikser' },
33
+ logger: { debug() {}, error() {} },
34
+ })
35
+
36
+ const request = (method) => ({
37
+ path: '/', method, ip: '203.0.113.9',
38
+ get: (name) => (name.toLowerCase() === 'depth' ? '1' : undefined),
39
+ headers: { authorization: 'Bearer stale' },
40
+ })
41
+
42
+ // Presented a credential, rejected, and the verifier says refreshing will
43
+ // not help.
44
+ const insufficient = {
45
+ verify: async () => false,
46
+ rejectionFor: () => ({ status: 403, code: 'insufficient_scope' }),
47
+ }
48
+ // Presented a credential, rejected, no refinement — the ordinary 401.
49
+ const invalid = { verify: async () => false }
50
+
51
+ // A verifier that throws — a store that cannot be read, a hash library that
52
+ // blew up. The root answers 401 rather than 500, because a client that can
53
+ // retry with credentials is more use than a stack trace, and this branch has
54
+ // its own `res.set` call that a mutation can change independently of the
55
+ // ordinary one.
56
+ const throwing = { verify: async () => { throw new Error('store unreadable') } }
57
+
58
+ describe('when the verifier itself fails', () => {
59
+ for (const method of ['OPTIONS', 'PROPFIND']) {
60
+ it(`challenges with the configured bytes on ${method}`, async () => {
61
+ const res = fakeResponse()
62
+ await middleware(throwing)(request(method), res, () => {
63
+ assert.fail('the root must answer, not fall through')
64
+ })
65
+ assert.equal(res.statusCode, 401)
66
+ assert.equal(res.headers['www-authenticate'], 'Basic realm="mikser", charset="UTF-8"',
67
+ 'the error path must emit what the happy path emits, not its own header')
68
+ })
69
+ }
70
+ })
71
+
72
+ describe('an authenticated caller who is not permitted', () => {
73
+ for (const method of ['OPTIONS', 'PROPFIND']) {
74
+ it(`gets 403 and no challenge on ${method}`, async () => {
75
+ const res = fakeResponse()
76
+ await middleware(insufficient)(request(method), res, () => {
77
+ assert.fail('the root must answer, not fall through')
78
+ })
79
+ assert.equal(res.statusCode, 403)
80
+ assert.equal(res.headers['www-authenticate'], undefined,
81
+ 're-challenging credentials that already worked is an infinite prompt')
82
+ })
83
+
84
+ it(`still gets 401 with a challenge on ${method} when the credential is merely invalid`, async () => {
85
+ const res = fakeResponse()
86
+ await middleware(invalid)(request(method), res, () => {
87
+ assert.fail('the root must answer, not fall through')
88
+ })
89
+ assert.equal(res.statusCode, 401)
90
+ assert.match(res.headers['www-authenticate'] ?? '', /^Basic /)
91
+ })
92
+ }
93
+ })
@@ -140,18 +140,63 @@ describe('host in the wrong place', () => {
140
140
  })
141
141
 
142
142
  describe('the drive root on its own host', () => {
143
- it('announces itself as WebDAV, which is what the redirector asks first', async () => {
144
- // Answered without credentials on purpose: this is discovery, and it
145
- // is what the redirector asks BEFORE it has anything to offer. It
146
- // discloses that a DAV server is here and nothing about what is in it.
143
+ it('challenges an anonymous OPTIONS, so the session authenticates at the root', async () => {
144
+ // This used to answer 200 without credentials, and that is what cost
145
+ // Windows its stored credential. The redirector builds its session
146
+ // against the SERVER ROOT before it touches the path; told the root
147
+ // needed nothing, it established the session anonymously and attached
148
+ // credentials only reactively at the PROPFIND 401. That attachment did
149
+ // not survive a reboot or WebClient's idle teardown — the redirector
150
+ // asked again, got the same clean 200, and prompted a human rather
151
+ // than replaying Credential Manager, with the credential sitting in
152
+ // cmdkey the whole time.
153
+ //
154
+ // Apache mod_dav inside `Require valid-user` 401s here, and reconnects
155
+ // there are silent.
147
156
  const res = await request('OPTIONS', '/')
157
+ assert.equal(res.status, 401)
158
+ assert.match(res.headers['www-authenticate'] ?? '', /^Basic /,
159
+ 'Basic is the only scheme Explorer and Finder speak')
160
+ })
161
+
162
+ it('announces itself as WebDAV once the request authenticates', async () => {
163
+ const res = await request('OPTIONS', '/', { headers: { authorization: as('alice') } })
148
164
  assert.equal(res.status, 200)
149
- assert.match(res.headers.dav ?? '', /\b1\b/, 'the root must claim class 1')
150
165
  assert.match(res.headers.allow ?? '', /PROPFIND/)
166
+ assert.equal(res.headers['ms-author-via'], 'DAV')
151
167
  assert.equal(res.headers['cache-control'], 'no-cache',
152
168
  'a discovery answer that can be cached is one a client keeps refusing')
153
169
  })
154
170
 
171
+ it('advertises the same compliance classes as the endpoints beneath it', async () => {
172
+ // Finder refuses a read-write mount without class 2, and both it and
173
+ // the redirector read capability at the ROOT and decide the whole
174
+ // mount from it. Deliberately more than this resource can do on its
175
+ // own — RFC 4918 §18.2 wants LOCK from a class 2 resource and `Allow`
176
+ // says this one serves OPTIONS and PROPFIND. See respondOptions.
177
+ const res = await request('OPTIONS', '/', { headers: { authorization: as('alice') } })
178
+ for (const compliance of ['1', '2', '3']) {
179
+ assert.match(res.headers.dav ?? '', new RegExp(`\\b${compliance}\\b`),
180
+ `the root must claim class ${compliance}, as the endpoints do`)
181
+ }
182
+ })
183
+
184
+ it('keeps Allow truthful about what the root itself serves', async () => {
185
+ // The other half of advertising class 2: a client that asks what this
186
+ // resource permits must not be told it can LOCK here.
187
+ const res = await request('OPTIONS', '/', { headers: { authorization: as('alice') } })
188
+ assert.doesNotMatch(res.headers.allow ?? '', /LOCK/,
189
+ 'the root is read-only and must say so')
190
+ })
191
+
192
+ it('answers an authenticated OPTIONS on the listing too', async () => {
193
+ // The 207 carries a DAV header of its own, and a client reading class
194
+ // from either answer must get the same story.
195
+ const res = await propfind({ headers: { authorization: as('alice') } })
196
+ assert.equal(res.status, 207)
197
+ assert.match(res.headers.dav ?? '', /\b2\b/)
198
+ })
199
+
155
200
  it('challenges an anonymous listing, so a DAV client prompts', async () => {
156
201
  const res = await propfind()
157
202
  assert.equal(res.status, 401)
@@ -159,6 +204,47 @@ describe('the drive root on its own host', () => {
159
204
  'Basic is the only scheme Explorer and Finder speak')
160
205
  })
161
206
 
207
+ it('challenges an unknown user, which is what a challenge is for', async () => {
208
+ const res = await request('OPTIONS', '/', { headers: { authorization: as('dave') } })
209
+ assert.equal(res.status, 401)
210
+ })
211
+
212
+ it('challenges with the exact bytes this deployment configured', async () => {
213
+ // Exact, because the parameter that is NOT here is the finding:
214
+ // `charset="UTF-8"` is advisory (RFC 7617 §2.1) and the Windows
215
+ // WebDAV redirector appears not to parse it — a mapped drive carrying
216
+ // it never reconnects from stored credentials. The root follows the
217
+ // auth plugin's choice rather than carrying a knob of its own, and
218
+ // this fixture leaves it at the default.
219
+ for (const method of ['OPTIONS', 'PROPFIND']) {
220
+ const res = await request(method, '/', { headers: { depth: '1' } })
221
+ assert.equal(res.status, 401, `${method} must challenge`)
222
+ assert.equal(res.headers['www-authenticate'], 'Basic realm="mikser", charset="UTF-8"',
223
+ `${method} must carry what this deployment's auth configured`)
224
+ }
225
+ })
226
+
227
+ it('accepts a bearer-carrying client at the root', async () => {
228
+ // rclone and curl carry a token rather than Basic. Challenging
229
+ // OPTIONS must not turn them away — the verifier already accepts
230
+ // them, and OPTIONS now goes through the same verifier PROPFIND does.
231
+ const listing = await propfind({ headers: { authorization: as('carol') } })
232
+ assert.equal(listing.status, 207, 'precondition: this principal can list')
233
+ const res = await request('OPTIONS', '/', { headers: { authorization: as('carol') } })
234
+ assert.equal(res.status, 200, 'the same credentials must satisfy OPTIONS')
235
+ })
236
+
237
+ it('leaves the endpoint mounts beneath it untouched', async () => {
238
+ // A different code path — the Nephele mounts, not this middleware —
239
+ // and the report asked for it to stay exactly as it was.
240
+ const res = await request('OPTIONS', '/SkinCheck',
241
+ { headers: { authorization: as('alice') } })
242
+ assert.equal(res.status, 200)
243
+ for (const compliance of ['1', '2']) {
244
+ assert.match(res.headers.dav ?? '', new RegExp(`\\b${compliance}\\b`))
245
+ }
246
+ })
247
+
162
248
  it('lists only the endpoints the user may read', async () => {
163
249
  const xml = (await propfind({ headers: { authorization: as('alice') } })).body
164
250
  assert.match(xml, /<D:href>\/SkinCheck\/<\/D:href>/, xml)