hfs 3.3.3 → 3.3.4

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 (49) hide show
  1. package/admin/assets/AccountsPage-CnZGcUox.js +6 -0
  2. package/admin/assets/{ArrayField-B20NUH0C.js → ArrayField-F7fpCJIv.js} +1 -1
  3. package/admin/assets/{ConfigFilePage-Dw0-gus3.js → ConfigFilePage-CEgZ8Wq4.js} +1 -1
  4. package/admin/assets/{CustomHtmlPage-CLGyZfZ8.js → CustomHtmlPage-DDUNxF1H.js} +1 -1
  5. package/admin/assets/{DataTable-BwyFEGxv.js → DataTable-D_TeYxvk.js} +1 -1
  6. package/admin/assets/{DateTimeField-ZUy9IBhM.js → DateTimeField-CsIdkTqz.js} +1 -1
  7. package/admin/assets/{EventManager-DomWgRty.js → EventManager--HiXIAY0.js} +1 -1
  8. package/admin/assets/{FileField-Bj3P7JUc.js → FileField-HsDCdr5S.js} +1 -1
  9. package/admin/assets/{InternetPage-CGMQCcBM.js → InternetPage-DDwKrXJH.js} +1 -1
  10. package/admin/assets/{LangPage-DGFrOItT.js → LangPage-Dd8hi_9K.js} +1 -1
  11. package/admin/assets/{LogsPage-CvCYNhID.js → LogsPage-BcPfugNY.js} +1 -1
  12. package/admin/assets/{MenuButton-BTRAUH7H.js → MenuButton-cuehhVig.js} +1 -1
  13. package/admin/assets/{MonitorPage-CVa2ILH2.js → MonitorPage-Bre9d6MA.js} +1 -1
  14. package/admin/assets/OptionsPage-Dd_cRDf4.js +3 -0
  15. package/admin/assets/{PluginsPage-CIOnncph.js → PluginsPage-DJiseD4G.js} +1 -1
  16. package/admin/assets/{Tabs-zcHwsK2s.js → Tabs-C87E85Fh.js} +1 -1
  17. package/admin/assets/{TextEditor-CDOjRy40.js → TextEditor-Cr0VI9Xi.js} +1 -1
  18. package/admin/assets/{VfsPathField-DHifSPJi.js → VfsPathField-D1BkfhAM.js} +1 -1
  19. package/admin/assets/{agentIcons-DB1m4pGa.js → agentIcons-CDoUTPUC.js} +1 -1
  20. package/admin/assets/{baseUrl-Bt9R8Da2.js → baseUrl-CnrV7Huu.js} +1 -1
  21. package/admin/assets/{icons-DR_Rvxtn.js → icons-DJJYoK9E.js} +1 -1
  22. package/admin/assets/{index-B3difDBD.js → index-DwXlUHAs.js} +3 -3
  23. package/admin/assets/{routing-CBrDVeFB.js → routing-BQpoUdjq.js} +1 -1
  24. package/admin/index.html +1 -1
  25. package/frontend/assets/{dist-legacy-CSuZ0eoA.js → dist-legacy-CnYHQXZ6.js} +1 -1
  26. package/frontend/assets/index-legacy-JYULclbY.js +1 -0
  27. package/frontend/assets/{lib-legacy-I77jyH6j.js → lib-legacy-3xYueNHt.js} +1 -1
  28. package/frontend/assets/{sha512-legacy-DxvM1xc4.js → sha512-legacy-Cl3f_PhN.js} +1 -1
  29. package/frontend/index.html +1 -1
  30. package/npm-shrinkwrap.json +63 -63
  31. package/package.json +1 -1
  32. package/plugins/antibrute/plugin.js +13 -6
  33. package/src/adminApis.js +2 -1
  34. package/src/api.accounts.js +9 -4
  35. package/src/api.auth.js +10 -2
  36. package/src/auth.js +12 -7
  37. package/src/deletePermission.js +120 -0
  38. package/src/events.js +6 -0
  39. package/src/index.js +3 -1
  40. package/src/listen.js +2 -1
  41. package/src/middlewares.js +27 -9
  42. package/src/perm.js +26 -4
  43. package/src/serveGuiAndSharedFiles.js +3 -2
  44. package/src/sessionStamp.js +53 -0
  45. package/src/uploadOwners.js +4 -2
  46. package/src/vfs.js +72 -34
  47. package/admin/assets/AccountsPage-C_ayTmn7.js +0 -3
  48. package/admin/assets/OptionsPage-C3cprG9u.js +0 -3
  49. package/frontend/assets/index-legacy-tk5ZmBDE.js +0 -1
package/src/api.auth.js CHANGED
@@ -16,6 +16,7 @@ const events_1 = __importDefault(require("./events"));
16
16
  const misc_1 = require("./misc");
17
17
  const uploadOwners_1 = require("./uploadOwners");
18
18
  const node_crypto_1 = require("node:crypto");
19
+ const sessionStamp_1 = require("./sessionStamp");
19
20
  const ongoingLogins = {}; // store data that doesn't fit session object
20
21
  const keepSessionAlive = (0, config_1.defineConfig)(misc_1.CFG.keep_session_alive, true);
21
22
  const fakeSrpSecret = (0, node_crypto_1.randomBytes)(32);
@@ -76,13 +77,14 @@ exports.authApis = {
76
77
  if (account && (0, middlewares_1.failAllowNet)(ctx, account))
77
78
  return unauthorized();
78
79
  try { // unknown users complete step 1 so only a full failed login can reveal and penalize the attempt
80
+ const stamp = account && (0, sessionStamp_1.getSessionStamp)(account.username);
79
81
  const { srpServer, ...rest } = await (0, auth_1.srpServerStep1)(account || fakeSrpAccount(username));
80
82
  // keep the public handshake identifier independent of predictable application PRNG state
81
83
  const sid = (0, node_crypto_1.randomUUID)();
82
84
  ongoingLogins[sid] = srpServer;
83
85
  setTimeout(() => delete ongoingLogins[sid], 60_000); // client must complete api sequence (loginSrp2) within 1 minute or will be discarded to avoid memory leaks
84
86
  // 60-second expiry bounded 5k challenges to ~37 MB in a local saturation test, so an additional cap isn't justified
85
- ctx.session.loggingIn = { username, sid }; // temporarily store until process is complete
87
+ ctx.session.loggingIn = { username, sid, stamp }; // temporarily store until process is complete
86
88
  return rest;
87
89
  }
88
90
  catch (code) {
@@ -109,7 +111,7 @@ exports.authApis = {
109
111
  return new apiMiddleware_1.ApiError(const_1.HTTP_SERVER_ERROR);
110
112
  if (!ctx.session.loggingIn)
111
113
  return new apiMiddleware_1.ApiError(const_1.HTTP_CONFLICT);
112
- const { username, sid } = ctx.session.loggingIn;
114
+ const { username, sid, stamp } = ctx.session.loggingIn;
113
115
  delete ctx.session.loggingIn;
114
116
  const step1 = ongoingLogins[sid];
115
117
  if (!step1)
@@ -117,6 +119,10 @@ exports.authApis = {
117
119
  try {
118
120
  const M2 = await step1.step2(BigInt(pubKey), BigInt(proof))
119
121
  .catch(() => { throw ''; }); // falsy value for later
122
+ // an old-password handshake must not mint a session after credentials have been revoked
123
+ const account = (0, perm_1.getAccount)(username);
124
+ if (!account || !stamp || stamp !== (0, sessionStamp_1.getSessionStamp)(account.username))
125
+ throw '';
120
126
  await (0, auth_1.setLoggedIn)(ctx, username, 'srp');
121
127
  return {
122
128
  proof: String(M2),
@@ -150,6 +156,8 @@ exports.authApis = {
150
156
  if (!salt || !verifier)
151
157
  return new apiMiddleware_1.ApiError(const_1.HTTP_BAD_REQUEST, 'missing parameters');
152
158
  await (0, perm_1.updateAccount)(a, a => (0, perm_1.saveSrpInfo)(a, salt, verifier));
159
+ if (ctx.session?.username === a.username)
160
+ ctx.session.stamp = (0, sessionStamp_1.getSessionStamp)(a.username);
153
161
  delete a.require_password_change;
154
162
  return {};
155
163
  }
package/src/auth.js CHANGED
@@ -36,7 +36,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.invalidateSessionBefore = void 0;
40
39
  exports.srpServerStep1 = srpServerStep1;
41
40
  exports.srpCheck = srpCheck;
42
41
  exports.getCurrentUsername = getCurrentUsername;
@@ -51,6 +50,7 @@ const cross_1 = require("./cross");
51
50
  const expiringCache_1 = require("./expiringCache");
52
51
  const node_crypto_1 = require("node:crypto");
53
52
  const events_1 = __importDefault(require("./events"));
53
+ const sessionStamp_1 = require("./sessionStamp");
54
54
  const srp6aNimbusRoutines = new srp.SRPRoutines(new srp.SRPParameters());
55
55
  async function srpServerStep1(account) {
56
56
  if (!account.srp)
@@ -67,13 +67,17 @@ async function srpCheck(username, password) {
67
67
  const account = (0, perm_1.getAccount)(username);
68
68
  if (!account?.srp || !password)
69
69
  return;
70
- const k = (0, node_crypto_1.createHash)('sha256').update(username + password + account.srp).digest("hex");
70
+ const verifier = account.srp;
71
+ const sessionStamp = (0, sessionStamp_1.getSessionStamp)(account.username);
72
+ const k = (0, node_crypto_1.createHash)('sha256').update(username + password + verifier).digest("hex");
71
73
  const good = await cache.try(k, async () => {
72
74
  const { srpServer, salt, pubKey } = await srpServerStep1(account);
73
75
  const client = await (0, srp_1.srpClientPart)(srp, username, password, salt, pubKey);
74
76
  return srpServer.step2(client.A, client.M1).then(() => true, () => false);
75
77
  });
76
- return good ? account : undefined;
78
+ // verification can finish after a password change or account replacement
79
+ return good && (0, perm_1.getAccount)(username) === account && account.srp === verifier && (0, sessionStamp_1.getSessionStamp)(account.username) === sessionStamp
80
+ ? account : undefined;
77
81
  }
78
82
  function getCurrentUsername(ctx) {
79
83
  return ctx.state.account?.username || '';
@@ -106,8 +110,11 @@ async function setLoggedIn(ctx, username, via) {
106
110
  const a = ctx.state.account = (0, perm_1.getAccount)(username);
107
111
  if (!a)
108
112
  return;
113
+ const sessionStamp = (0, sessionStamp_1.getSessionStamp)(a.username);
109
114
  const result = await events_1.default.emitAsync('finalizingLogin', { ctx, username: a.username, via, inputs: { ...ctx.state.params, ...ctx.query } });
110
- const error = result?.find(x => x && lodash_1.default.isString(x)) || result?.isDefaultPrevented() && "Login denied";
115
+ // async plugin checks must not let a revoked login acquire the new session stamp
116
+ const error = result?.find(x => x && lodash_1.default.isString(x)) || (result?.isDefaultPrevented()
117
+ || !sessionStamp || (0, perm_1.getAccount)(username) !== a || (0, sessionStamp_1.getSessionStamp)(a.username) !== sessionStamp) && "Login denied";
111
118
  if (error) {
112
119
  // restore the session identity: the candidate account was exposed only for plugin checks
113
120
  ctx.state.account = (0, perm_1.getAccount)(s.username, false);
@@ -118,13 +125,11 @@ async function setLoggedIn(ctx, username, via) {
118
125
  if (s.username !== normalized)
119
126
  delete s.allowNet; // discard restrictions cached for another identity before replacing the session account
120
127
  s.username = normalized;
121
- s.ts = Date.now();
122
128
  s.ip = ctx.ip;
129
+ s.stamp = sessionStamp;
123
130
  const k = cross_const_1.ALLOW_SESSION_IP_CHANGE;
124
131
  s[k] = k in ctx.query || Boolean(ctx.state.params?.[k]) || undefined; // login APIs will get ctx.state.params, others can rely on ctx.query
125
132
  if (!a.expire && a.days_to_live)
126
133
  (0, perm_1.updateAccount)(a, { expire: new Date(Date.now() + a.days_to_live * cross_1.DAY) });
127
134
  await events_1.default.emitAsync('login', ctx);
128
135
  }
129
- // since session are currently stored in cookies, we need to store this information
130
- exports.invalidateSessionBefore = new Map();
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.checkDeletePermission = checkDeletePermission;
7
+ const promises_1 = __importDefault(require("node:fs/promises"));
8
+ const node_path_1 = require("node:path");
9
+ const cross_1 = require("./cross");
10
+ const const_1 = require("./const");
11
+ const events_1 = __importDefault(require("./events"));
12
+ const uploadOwners_1 = require("./uploadOwners");
13
+ const vfs_1 = require("./vfs");
14
+ // unlike a listing, deletion affects hidden entries and must fail if any directory cannot be read
15
+ async function checkDeletePermission(node, ctx) {
16
+ if ((0, vfs_1.statusCodeForMissingPerm)(node, 'can_delete', ctx))
17
+ return false;
18
+ if (!node.source || !(await promises_1.default.lstat(node.source)).isDirectory())
19
+ return true;
20
+ return recur(node, []);
21
+ async function recur(parent, scopes) {
22
+ // explicit VFS entries are not deletable, even when their source is several levels below this directory
23
+ for (const child of explicitNodes(parent)) {
24
+ if (!child.source)
25
+ continue;
26
+ const path = (0, node_path_1.relative)((0, node_path_1.resolve)(parent.source), (0, node_path_1.resolve)(child.source));
27
+ if (path === '..' || path.startsWith('..' + node_path_1.sep) || (0, node_path_1.isAbsolute)(path))
28
+ continue;
29
+ try {
30
+ await promises_1.default.lstat(child.source);
31
+ }
32
+ catch (error) {
33
+ if (error.code === 'ENOENT')
34
+ continue;
35
+ throw error;
36
+ }
37
+ ctx.status = const_1.HTTP_FORBIDDEN;
38
+ await reportBlocked(child);
39
+ return false;
40
+ }
41
+ const inherited = (0, vfs_1.permsFromParent)(parent, {}) || {};
42
+ const keys = new Set(['can_delete']);
43
+ for (const key of keys) {
44
+ addReferences(parent[key] ?? cross_1.defaultPerms[key]);
45
+ addReferences(inherited[key] ?? cross_1.defaultPerms[key]);
46
+ }
47
+ // skip scanning descendants only when inherited permissions guarantee deletion and no deeper rule or plugin can override them
48
+ if (!scopes.some(scope => hasRules(scope.node, keys)) && !hasRules(parent, keys)
49
+ && !cross_1.PERM_KEYS.some(key => (0, cross_1.isWhoObject)(inherited[key]))
50
+ && !events_1.default.hasOtherListeners('checkVfsPermission', uploadOwners_1.grantUploadOwnerDelete)
51
+ && !(0, vfs_1.statusCodeForMissingPerm)(inherited, 'can_delete', ctx, false, false))
52
+ return true;
53
+ // only literal path prefixes can be exhausted by inheritMasks; other patterns may match again deeper down
54
+ const persistentMasks = Object.fromEntries(Object.entries(parent.masks || {}).filter(([mask, mods]) => mods && (cross_1.PERM_KEYS.some(key => mods[key] !== undefined) || hasRules(mods, new Set(cross_1.PERM_KEYS)))
55
+ && (!mask.includes('/') || /[!*?\[\]{}()|\\]/.test(mask.split('/')[0]))));
56
+ if (Object.keys(persistentMasks).length)
57
+ scopes = [...scopes, { node: { masks: persistentMasks }, path: '' }];
58
+ for await (const entry of await promises_1.default.opendir(parent.source)) {
59
+ if (ctx.isAborted())
60
+ throw Error("Deletion cancelled");
61
+ const source = (0, node_path_1.join)(parent.source, entry.name);
62
+ const name = (0, vfs_1.getVirtualName)(entry.name, parent, source);
63
+ const explicit = (0, vfs_1.getChildBySource)(parent, source);
64
+ // permission masks use the target type, but deletion must never recurse through the link
65
+ const isFolder = entry.isSymbolicLink()
66
+ ? await promises_1.default.stat(source).then(stats => stats.isDirectory(), error => {
67
+ if (error.code === 'ENOENT')
68
+ return false;
69
+ throw error;
70
+ }) : entry.isDirectory();
71
+ const child = {
72
+ ...explicit, original: explicit, source, isFolder,
73
+ rename: explicit?.rename || (0, vfs_1.renameUnderPath)(parent.rename, entry.name),
74
+ };
75
+ const nextScopes = scopes.map(scope => ({ node: scope.node, path: scope.path + name }));
76
+ for (const scope of [...nextScopes].reverse()) {
77
+ (0, vfs_1.parentMaskApplier)(scope.node, true)(child, scope.path);
78
+ (0, vfs_1.parentMaskApplier)(scope.node)(child, name);
79
+ }
80
+ const evaluated = (0, vfs_1.applyParentToChild)(child, parent, name);
81
+ if ((0, vfs_1.statusCodeForMissingPerm)(evaluated, 'can_delete', ctx)) {
82
+ await reportBlocked(evaluated);
83
+ return false;
84
+ }
85
+ // dirents do not follow symlinks, including dangling links
86
+ if (entry.isDirectory() && !await recur(evaluated, nextScopes.map(scope => ({ ...scope, path: scope.path + '/' }))))
87
+ return false;
88
+ }
89
+ return true;
90
+ function addReferences(who) {
91
+ if ((0, cross_1.isWhoObject)(who)) {
92
+ if (who.this !== undefined)
93
+ addReferences(who.this);
94
+ if (who.children !== undefined)
95
+ addReferences(who.children);
96
+ }
97
+ else if (typeof who === 'string' && cross_1.PERM_KEYS.includes(who))
98
+ keys.add(who);
99
+ }
100
+ }
101
+ async function reportBlocked(blocked) {
102
+ // a deletion error must not disclose names hidden by access permissions
103
+ let visible = blocked;
104
+ for (let ancestor = blocked; ancestor && ancestor !== node; ancestor = ancestor.parent)
105
+ if (!(0, vfs_1.hasPermission)(ancestor, 'can_see', ctx) || !await (0, vfs_1.urlToNode)(ancestor.vfsPath, ctx)
106
+ || ancestor.parent && !(0, vfs_1.hasPermission)(ancestor.parent, 'can_list', ctx))
107
+ visible = ancestor.parent || node;
108
+ ctx.body = { uri: '/' + visible.vfsPath };
109
+ }
110
+ function* explicitNodes(parent) {
111
+ for (const child of parent.children || []) {
112
+ const evaluated = (0, vfs_1.applyParentToChild)(child, parent);
113
+ yield evaluated;
114
+ yield* explicitNodes(evaluated);
115
+ }
116
+ }
117
+ }
118
+ function hasRules(node, keys) {
119
+ return Boolean(node.children?.length || Object.values(node.masks || {}).some(mods => mods && (Array.from(keys).some(key => mods[key] !== undefined) || hasRules(mods, keys))));
120
+ }
package/src/events.js CHANGED
@@ -56,6 +56,12 @@ class BetterEventEmitter {
56
56
  anyListener(event) {
57
57
  return Boolean(this.listeners.get(event)?.size);
58
58
  }
59
+ hasOtherListeners(event, mine) {
60
+ for (const x of this.listeners.get(event) || [])
61
+ if (x !== mine)
62
+ return true;
63
+ return false;
64
+ }
59
65
  emit(event, ...args) {
60
66
  let cbs = this.listeners.get(event);
61
67
  if (!cbs?.size)
package/src/index.js CHANGED
@@ -47,8 +47,10 @@ exports.app = new koa_1.default({ keys });
47
47
  exports.app.use(middlewares_1.sessionMiddleware)
48
48
  .use(selfCheck_1.selfCheckMiddleware)
49
49
  .use(acme_1.acmeMiddleware)
50
- .use(middlewares_1.prepareState)
50
+ .use(middlewares_1.prepareConnection)
51
+ .use(middlewares_1.blockFilter) // denied clients must not reach credential verification or its session/account side effects
51
52
  .use(geo_1.geoFilter)
53
+ .use(middlewares_1.prepareState)
52
54
  .use(ips_1.trackIpsMw)
53
55
  .use(middlewares_1.gzipper)
54
56
  .use(middlewares_1.paramsDecoder) // must be done before plugins, so they can manipulate params
package/src/listen.js CHANGED
@@ -70,6 +70,7 @@ const consoleLog_1 = require("./consoleLog");
70
70
  const first_1 = require("./first");
71
71
  const fileAttr_1 = require("./fileAttr");
72
72
  const uploadOwners_1 = require("./uploadOwners");
73
+ const sessionStamp_1 = require("./sessionStamp");
73
74
  let httpSrv;
74
75
  let httpsSrv;
75
76
  // the update relaunch can keep a bridge process alive, so we proactively close listeners here to release ports before the next binary binds; do it before (5) the storage file is closed, because sockets write there
@@ -88,7 +89,7 @@ const commonServerOptions = {
88
89
  };
89
90
  // these are properties that can be assigned to the server object
90
91
  const commonServerAssign = { headersTimeout: 30_000, timeout: misc_1.MINUTE }; // 'headersTimeout' is not recognized by type lib, and 'timeout' is not effective when passed in parameters
91
- const readyToListen = Promise.all([persistence_1.storedMap.isOpening(), fileAttr_1.fileAttrDb.isOpening(), uploadOwners_1.uploadOwners.isOpening(), events_1.default.once('app')]);
92
+ const readyToListen = Promise.all([persistence_1.storedMap.isOpening(), sessionStamp_1.sessionStampsReady, fileAttr_1.fileAttrDb.isOpening(), uploadOwners_1.uploadOwners.isOpening(), events_1.default.once('app')]);
92
93
  const considerHttp = (0, misc_1.debounceAsync)(async () => {
93
94
  await readyToListen;
94
95
  void stopServer(httpSrv);
@@ -4,7 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  return (mod && mod.__esModule) ? mod : { "default": mod };
5
5
  };
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.sessionMiddleware = exports.paramsDecoder = exports.prepareState = exports.someSecurity = exports.cloudflareDetected = exports.headRequests = exports.gzipper = exports.sessionDuration = void 0;
7
+ exports.sessionMiddleware = exports.paramsDecoder = exports.prepareState = exports.blockFilter = exports.prepareConnection = exports.someSecurity = exports.cloudflareDetected = exports.headRequests = exports.gzipper = exports.sessionDuration = void 0;
8
8
  exports.getProxyDetected = getProxyDetected;
9
9
  exports.failAllowNet = failAllowNet;
10
10
  const koa_compress_1 = __importDefault(require("koa-compress"));
@@ -15,8 +15,11 @@ const block_1 = require("./block");
15
15
  const perm_1 = require("./perm");
16
16
  const connections_1 = require("./connections");
17
17
  const auth_1 = require("./auth");
18
+ const sessionStamp_1 = require("./sessionStamp");
18
19
  const zlib_1 = require("zlib");
19
20
  const listen_1 = require("./listen");
21
+ const roots_1 = require("./roots");
22
+ const node_net_1 = require("node:net");
20
23
  const config_1 = require("./config");
21
24
  const koa_session_1 = __importDefault(require("koa-session"));
22
25
  const index_1 = require("./index");
@@ -53,8 +56,6 @@ exports.headRequests = headRequests;
53
56
  let proxyDetected;
54
57
  const someSecurity = (ctx, next) => {
55
58
  enforceSessionIp(ctx);
56
- if (!ctx.state.skipFilters && (0, block_1.applyBlock)(ctx.socket, ctx.ip))
57
- return;
58
59
  const decodedPath = (0, misc_1.try_)(() => decodeURI(ctx.path));
59
60
  if (!decodedPath || (0, misc_1.hasDirTraversal)(decodedPath))
60
61
  return;
@@ -70,6 +71,16 @@ const someSecurity = (ctx, next) => {
70
71
  return next();
71
72
  };
72
73
  exports.someSecurity = someSecurity;
74
+ const prepareConnection = (ctx, next) => {
75
+ // normalize once so auth, filters and logging agree on the same client address
76
+ ctx.request.ip = (0, connections_1.normalizeIp)(ctx.ip);
77
+ ctx.state.connection = (0, connections_1.socket2connection)(ctx.socket);
78
+ (0, connections_1.updateConnectionForCtx)(ctx);
79
+ return next();
80
+ };
81
+ exports.prepareConnection = prepareConnection;
82
+ const blockFilter = (ctx, next) => !ctx.state.skipFilters && (0, block_1.applyBlock)(ctx.socket, ctx.ip) || next();
83
+ exports.blockFilter = blockFilter;
73
84
  function enforceSessionIp(ctx) {
74
85
  const s = ctx.session;
75
86
  if (!s?.username || s[misc_1.ALLOW_SESSION_IP_CHANGE])
@@ -88,8 +99,6 @@ function getProxyDetected() {
88
99
  return proxyDetected && { from: proxyDetected.socket.remoteAddress, for: proxyDetected.get('X-Forwarded-For') };
89
100
  }
90
101
  const prepareState = async (ctx, next) => {
91
- // normalize once so auth, filters and logging agree on the same client address
92
- ctx.request.ip = (0, connections_1.normalizeIp)(ctx.ip);
93
102
  // invalidate before account resolution; someSecurity calls again to bind logins made below
94
103
  enforceSessionIp(ctx);
95
104
  // rootsMiddleware consults proxy-aware admin access before someSecurity runs
@@ -107,12 +116,13 @@ const prepareState = async (ctx, next) => {
107
116
  }
108
117
  const s = ctx.session;
109
118
  if (s?.username) {
110
- if (s.ts < auth_1.invalidateSessionBefore.get(s?.username))
119
+ const account = (0, perm_1.getAccount)(s.username, false);
120
+ if (!account || !s.stamp || s.stamp !== (0, sessionStamp_1.getSessionStamp)(s.username)) {
111
121
  delete s.username;
122
+ delete s.stamp;
123
+ }
112
124
  s.maxAge = exports.sessionDuration.compiled();
113
125
  }
114
- // calculate these once and for all
115
- ctx.state.connection = (0, connections_1.socket2connection)(ctx.socket);
116
126
  // explicit credentials and existing sessions must take precedence, so a matching IP cannot override a chosen account
117
127
  let via;
118
128
  let a = await urlLogin() || await getHttpAccount() || !s?.username && autoLogin();
@@ -135,7 +145,7 @@ const prepareState = async (ctx, next) => {
135
145
  ctx.headers['x-username'] = a.username; // give an easier way to determine if the login was successful
136
146
  }
137
147
  ctx.state.revProxyPath = ctx.get('x-forwarded-prefix');
138
- (0, connections_1.updateConnectionForCtx)(ctx);
148
+ (0, connections_1.updateConnectionForCtx)(ctx); // publish the resolved account after the initial pre-auth connection update
139
149
  await next();
140
150
  function urlLogin() {
141
151
  via = 'url';
@@ -160,6 +170,14 @@ const prepareState = async (ctx, next) => {
160
170
  catch { }
161
171
  }
162
172
  function autoLogin() {
173
+ const host = ctx.host.toLowerCase();
174
+ const hostname = (0, misc_1.normalizeHost)(host);
175
+ // a rebinding page uses the victim's real IP, but retains the attacker's hostname
176
+ if (hostname !== 'localhost' && !(0, node_net_1.isIP)(hostname)
177
+ // URL normalization matches browsers' default-port removal and internationalized domain names
178
+ && host !== (listen_1.baseUrl.get() && (0, misc_1.try_)(() => new URL(listen_1.baseUrl.get()).host.toLowerCase()))
179
+ && roots_1.roots.compiled()(host) === undefined)
180
+ return;
163
181
  via = 'net';
164
182
  // keep the mask direct so group inheritance cannot make identity depend on account order
165
183
  return Object.values(perm_1.accounts.get()).find(a => (0, perm_1.accountCanLogin)(a) && a.auto_login_net && (0, misc_1.netMatches)(ctx.ip, a.auto_login_net, true));
package/src/perm.js CHANGED
@@ -12,6 +12,7 @@ exports.getAccount = getAccount;
12
12
  exports.saveSrpInfo = saveSrpInfo;
13
13
  exports.createAdmin = createAdmin;
14
14
  exports.updateAccount = updateAccount;
15
+ exports.invalidateAccountSessions = invalidateAccountSessions;
15
16
  exports.normalizeUsername = normalizeUsername;
16
17
  exports.renameAccount = renameAccount;
17
18
  exports.addAccount = addAccount;
@@ -29,6 +30,7 @@ const config_1 = require("./config");
29
30
  const tssrp6a_1 = require("tssrp6a");
30
31
  const events_1 = __importDefault(require("./events"));
31
32
  const auth_1 = require("./auth");
33
+ const sessionStamp_1 = require("./sessionStamp");
32
34
  // provides the username and all other usernames it inherits based on the 'belongs' attribute. Useful to check permissions
33
35
  function expandUsername(who) {
34
36
  const ret = new Set();
@@ -79,6 +81,7 @@ async function createAdmin(password, username = 'admin') {
79
81
  }
80
82
  const srp6aNimbusRoutines = new tssrp6a_1.SRPRoutines(new tssrp6a_1.SRPParameters());
81
83
  async function updateAccount(account, change) {
84
+ await sessionStamp_1.sessionStampsReady;
82
85
  const jsonWas = JSON.stringify(account);
83
86
  const { username: usernameWas } = account;
84
87
  if (typeof change === 'function')
@@ -121,8 +124,27 @@ async function updateAccount(account, change) {
121
124
  if (jsonWas !== JSON.stringify(account)) // this test will miss the 'username' field, because hidden, but renameAccount is already calling saveAccountsASAP
122
125
  saveAccountsAsap();
123
126
  }
124
- const saveAccountsAsap = config_1.saveConfigAsap;
127
+ function reconcileAccountSessions() {
128
+ (0, sessionStamp_1.syncSessionStamps)(lodash_1.default.mapKeys(exports.accounts.get(), (_account, username) => normalizeUsername(username)));
129
+ }
130
+ function saveAccountsAsap() {
131
+ // API callers need the updated stamp before preserving their own session in the response
132
+ reconcileAccountSessions();
133
+ (0, config_1.saveConfigAsap)();
134
+ }
135
+ function invalidateAccountSessions(account) {
136
+ const found = typeof account === 'string' ? getAccount(account) : account;
137
+ if (!found)
138
+ return false;
139
+ (0, sessionStamp_1.renewSessionStamp)(found.username);
140
+ return true;
141
+ }
125
142
  exports.accounts = (0, config_1.defineConfig)(misc_1.CFG.accounts, {});
143
+ exports.accounts.sub(async () => {
144
+ await sessionStamp_1.sessionStampsReady;
145
+ // do not debounce: removing and recreating a username must not be coalesced into one change
146
+ reconcileAccountSessions();
147
+ });
126
148
  exports.accounts.sub(lodash_1.default.debounce(obj => {
127
149
  // consider some validation here, in case of manual edit of the config
128
150
  lodash_1.default.each(obj, (rec, k) => {
@@ -200,14 +222,14 @@ function addAccount(username, props, updateExisting = false) {
200
222
  if (account && !updateExisting)
201
223
  return;
202
224
  account = (0, misc_1.setHidden)(account || {}, { username }); // hidden so that stringification won't include it
203
- Object.assign(account, lodash_1.default.pickBy(props, Boolean));
204
225
  exports.accounts.set(was => Object.assign(was, { [username]: account }));
205
- return updateAccount(account, account).then(() => account);
226
+ return updateAccount(account, lodash_1.default.pickBy(props, Boolean)).then(() => account);
206
227
  }
207
228
  function delAccount(username) {
208
229
  if (!getAccount(username))
209
230
  return false;
210
- exports.accounts.set(was => lodash_1.default.omit(was, normalizeUsername(username)));
231
+ username = normalizeUsername(username);
232
+ exports.accounts.set(was => lodash_1.default.omit(was, username));
211
233
  saveAccountsAsap();
212
234
  return true;
213
235
  }
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.serveSharedFiles = exports.guiFilesMiddleware = void 0;
7
+ const deletePermission_1 = require("./deletePermission");
7
8
  const path_1 = require("path");
8
9
  const vfs_1 = require("./vfs");
9
10
  const errorPages_1 = require("./errorPages");
@@ -106,9 +107,9 @@ const serveSharedFiles = async (ctx, next) => {
106
107
  const { source } = node;
107
108
  if (!source)
108
109
  return ctx.status = cross_const_1.HTTP_METHOD_NOT_ALLOWED;
109
- if ((0, vfs_1.statusCodeForMissingPerm)(node, 'can_delete', ctx))
110
- return;
111
110
  try {
111
+ if (!await (0, deletePermission_1.checkDeletePermission)(node, ctx))
112
+ return;
112
113
  if ((await events_1.default.emitAsync('deleting', { node, ctx }))?.isDefaultPrevented())
113
114
  return ctx.status = cross_const_1.HTTP_FAILED_DEPENDENCY;
114
115
  await (0, promises_2.rm)(source, { recursive: true });
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sessionStampsReady = void 0;
4
+ exports.getSessionStamp = getSessionStamp;
5
+ exports.renewSessionStamp = renewSessionStamp;
6
+ exports.syncSessionStamps = syncSessionStamps;
7
+ const persistence_1 = require("./persistence");
8
+ const node_crypto_1 = require("node:crypto");
9
+ const stamps = new Map();
10
+ const prefix = 'sessionStamp:';
11
+ // random signing keys already invalidate all cookies on restart
12
+ const persistent = process.env.COOKIE_SIGN_KEYS !== undefined;
13
+ exports.sessionStampsReady = load();
14
+ function getSessionStamp(username) {
15
+ return stamps.get(username)?.stamp;
16
+ }
17
+ function renewSessionStamp(username) {
18
+ const current = stamps.get(username);
19
+ if (current)
20
+ setStamp(username, current.credentials);
21
+ }
22
+ function syncSessionStamps(accounts) {
23
+ for (const username of stamps.keys())
24
+ if (!(username in accounts)) {
25
+ stamps.delete(username);
26
+ if (persistent)
27
+ void persistence_1.storedMap.del(prefix + username);
28
+ }
29
+ for (const [username, account] of Object.entries(accounts)) {
30
+ // compare values across reloads and restarts without duplicating credentials in data.kv
31
+ const credentials = (0, node_crypto_1.createHash)('sha256').update(JSON.stringify([
32
+ account?.srp, account?.password, account?.plugin,
33
+ ])).digest('hex');
34
+ if (stamps.get(username)?.credentials !== credentials)
35
+ setStamp(username, credentials);
36
+ }
37
+ }
38
+ function setStamp(username, credentials) {
39
+ const stamp = `${Date.now()}.${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`;
40
+ const record = { stamp, credentials };
41
+ stamps.set(username, record);
42
+ // accepted tradeoff: with fixed signing keys, a crash before this delayed write may lose a revocation
43
+ if (persistent)
44
+ void persistence_1.storedMap.put(prefix + username, record);
45
+ }
46
+ async function load() {
47
+ for await (const [key, record] of persistence_1.storedMap.iterator({ startsWith: prefix })) {
48
+ if (persistent)
49
+ stamps.set(key.slice(prefix.length), record);
50
+ else // re-enabling the same signing keys later must not revive cookies revoked during this run
51
+ void persistence_1.storedMap.del(key);
52
+ }
53
+ }
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.uploadOwners = exports.ownUploadDeleteHours = void 0;
7
+ exports.grantUploadOwnerDelete = grantUploadOwnerDelete;
7
8
  exports.isUnfinishedUploadOwner = isUnfinishedUploadOwner;
8
9
  exports.setUploadOwner = setUploadOwner;
9
10
  exports.moveUploadOwner = moveUploadOwner;
@@ -33,7 +34,8 @@ setInterval(() => {
33
34
  void exports.uploadOwners.del(k);
34
35
  }
35
36
  }, misc_1.MINUTE);
36
- events_1.default.on('checkVfsPermission', ({ node, perm, ctx }) => {
37
+ events_1.default.on('checkVfsPermission', grantUploadOwnerDelete);
38
+ function grantUploadOwnerDelete({ node, perm, ctx }) {
37
39
  if (perm !== 'can_delete' || !node.source)
38
40
  return;
39
41
  const { vfsPath } = node;
@@ -49,7 +51,7 @@ events_1.default.on('checkVfsPermission', ({ node, perm, ctx }) => {
49
51
  }
50
52
  if (matchesOwner(owner, ctx))
51
53
  return 0;
52
- });
54
+ }
53
55
  function matchesOwner(owner, ctx) {
54
56
  const username = (0, auth_1.getCurrentUsername)(ctx);
55
57
  const { sessionId } = owner;