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 +104 -0
- package/clients/windows/rclone-outbox.vbs +79 -0
- package/index.js +31 -0
- package/lib/authenticator.js +9 -3
- package/lib/root-listing.js +80 -23
- package/package.json +4 -4
- package/test/anonymous-discovery.test.js +102 -0
- package/test/derived.test.js +33 -4
- package/test/drive.test.js +5 -2
- package/test/root-denial.test.js +93 -0
- package/test/root-listing.test.js +91 -5
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
|
|
package/lib/authenticator.js
CHANGED
|
@@ -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',
|
|
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
|
package/lib/root-listing.js
CHANGED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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',
|
|
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',
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
})
|
package/test/derived.test.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/test/drive.test.js
CHANGED
|
@@ -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
|
-
|
|
105
|
-
|
|
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('
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
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)
|