@appweaver/core 1.1.2 → 1.1.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.
- package/package.json +1 -1
- package/resource/resource-loader.js +4 -0
- package/security/jwt/jwt-keys.js +22 -4
- package/storage/file-service.d.ts +2 -1
- package/storage/file-service.js +13 -1
- package/storage/filesystem-storage.js +42 -20
- package/utils/file-util.d.ts +21 -2
- package/utils/file-util.js +83 -6
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.loadResources = loadResources;
|
|
4
4
|
const typebox_1 = require("@sinclair/typebox");
|
|
5
5
|
const common_1 = require("@appweaver/common");
|
|
6
|
+
const utils_1 = require("../utils");
|
|
6
7
|
/**
|
|
7
8
|
* Loads application resources including models, services, policies, and routes.
|
|
8
9
|
*
|
|
@@ -50,6 +51,9 @@ async function loadModels(baseDir, modelPattern) {
|
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
}
|
|
54
|
+
// Reject file name patterns writing into a reserved storage path before any
|
|
55
|
+
// upload can reach the storage layer.
|
|
56
|
+
(0, utils_1.validateFileNamePatterns)(models);
|
|
53
57
|
// Map model variants to schemas using their corresponding suffixes
|
|
54
58
|
const resourceModels = {};
|
|
55
59
|
for (const model of Object.values(models)) {
|
package/security/jwt/jwt-keys.js
CHANGED
|
@@ -10,6 +10,12 @@ const promises_1 = __importDefault(require("node:fs/promises"));
|
|
|
10
10
|
const node_path_1 = __importDefault(require("node:path"));
|
|
11
11
|
const node_util_1 = require("node:util");
|
|
12
12
|
const node_crypto_1 = require("node:crypto");
|
|
13
|
+
/** Permissions of the directory holding the security keys (owner only). */
|
|
14
|
+
const KEYS_DIR_MODE = 0o700;
|
|
15
|
+
/** Permissions of the generated private key file (owner read/write only). */
|
|
16
|
+
const PRIVATE_KEY_MODE = 0o600;
|
|
17
|
+
/** Permissions of the generated public key file (owner read/write, others read). */
|
|
18
|
+
const PUBLIC_KEY_MODE = 0o644;
|
|
13
19
|
async function ensureSecurityKeys(publicKeyPath, privateKeyPath, generateIfNotExists) {
|
|
14
20
|
try {
|
|
15
21
|
await promises_1.default.access(publicKeyPath, promises_1.default.constants.F_OK);
|
|
@@ -30,10 +36,22 @@ async function generateSecurityKeys(publicKeyPath, privateKeyPath) {
|
|
|
30
36
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
31
37
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
|
|
32
38
|
});
|
|
33
|
-
await promises_1.default.mkdir(node_path_1.default.dirname(publicKeyPath), {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
await promises_1.default.mkdir(node_path_1.default.dirname(publicKeyPath), {
|
|
40
|
+
recursive: true,
|
|
41
|
+
mode: KEYS_DIR_MODE
|
|
42
|
+
});
|
|
43
|
+
await promises_1.default.writeFile(publicKeyPath, publicKey, {
|
|
44
|
+
encoding: 'utf8',
|
|
45
|
+
mode: PUBLIC_KEY_MODE
|
|
46
|
+
});
|
|
47
|
+
await promises_1.default.mkdir(node_path_1.default.dirname(privateKeyPath), {
|
|
48
|
+
recursive: true,
|
|
49
|
+
mode: KEYS_DIR_MODE
|
|
50
|
+
});
|
|
51
|
+
await promises_1.default.writeFile(privateKeyPath, privateKey, {
|
|
52
|
+
encoding: 'utf8',
|
|
53
|
+
mode: PRIVATE_KEY_MODE
|
|
54
|
+
});
|
|
37
55
|
}
|
|
38
56
|
async function loadSecurityKeys(publicKeyPath, privateKeyPath, generateIfNotExists) {
|
|
39
57
|
const keysExisted = await ensureSecurityKeys(publicKeyPath, privateKeyPath, generateIfNotExists);
|
|
@@ -14,7 +14,8 @@ export declare class FileService {
|
|
|
14
14
|
*
|
|
15
15
|
* @param {string} fileName - The name of the file to search for.
|
|
16
16
|
* @return {Promise<File>} A promise that resolves to the file object if found.
|
|
17
|
-
* @throws {HttpError} Throws an error if
|
|
17
|
+
* @throws {HttpError} Throws an error if the file name is placed under a reserved storage path, if a database error
|
|
18
|
+
* occurs, or if the file is not found.
|
|
18
19
|
*/
|
|
19
20
|
findByName(fileName: string): Promise<File>;
|
|
20
21
|
/**
|
package/storage/file-service.js
CHANGED
|
@@ -20,9 +20,11 @@ class FileService {
|
|
|
20
20
|
*
|
|
21
21
|
* @param {string} fileName - The name of the file to search for.
|
|
22
22
|
* @return {Promise<File>} A promise that resolves to the file object if found.
|
|
23
|
-
* @throws {HttpError} Throws an error if
|
|
23
|
+
* @throws {HttpError} Throws an error if the file name is placed under a reserved storage path, if a database error
|
|
24
|
+
* occurs, or if the file is not found.
|
|
24
25
|
*/
|
|
25
26
|
async findByName(fileName) {
|
|
27
|
+
this.assertPathNotReserved(fileName);
|
|
26
28
|
let file;
|
|
27
29
|
try {
|
|
28
30
|
file = (await this._db.client().file.findFirst({
|
|
@@ -135,6 +137,9 @@ class FileService {
|
|
|
135
137
|
userId: identity?.id,
|
|
136
138
|
userEmail: identity?.email
|
|
137
139
|
});
|
|
140
|
+
// The generated name may come from a name pattern configured as a function,
|
|
141
|
+
// which cannot be validated on startup, so it is checked here as well.
|
|
142
|
+
this.assertPathNotReserved(generatedName);
|
|
138
143
|
let nameRegenCount = 0;
|
|
139
144
|
while (await this._storage.exists(generatedName)) {
|
|
140
145
|
nameRegenCount++;
|
|
@@ -400,6 +405,13 @@ class FileService {
|
|
|
400
405
|
return deletedFiles;
|
|
401
406
|
}
|
|
402
407
|
/** @internal */
|
|
408
|
+
assertPathNotReserved(fileName) {
|
|
409
|
+
const reservedPath = (0, common_1.findReservedStoragePath)(fileName, common_1.config.STORAGE_RESERVED_PATHS);
|
|
410
|
+
if (reservedPath !== null) {
|
|
411
|
+
throw new errors_1.HttpError(`File path '${fileName}' is not allowed`, 400);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/** @internal */
|
|
403
415
|
async deleteSafe(fileName) {
|
|
404
416
|
let success = true;
|
|
405
417
|
try {
|
|
@@ -11,16 +11,19 @@ const promises_2 = require("node:stream/promises");
|
|
|
11
11
|
const common_1 = require("@appweaver/common");
|
|
12
12
|
class FilesystemStorage extends common_1.Storage {
|
|
13
13
|
/** @internal */
|
|
14
|
-
_dirPath = common_1.config.STORAGE_PATH;
|
|
14
|
+
_dirPath = node_path_1.default.resolve(common_1.config.STORAGE_PATH);
|
|
15
15
|
async onInit() {
|
|
16
|
-
const directoryExists = await this.
|
|
16
|
+
const directoryExists = await this.pathExists(this._dirPath);
|
|
17
17
|
if (!directoryExists) {
|
|
18
18
|
await promises_1.default.mkdir(this._dirPath, { recursive: true });
|
|
19
19
|
common_1.logger.info(`Storage directory initialized: ${this._dirPath}`);
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
async stream(fileName, start = 0, end) {
|
|
23
|
-
const filePath =
|
|
23
|
+
const filePath = this.resolveFilePath(fileName, 'stream');
|
|
24
|
+
if (!filePath) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
24
27
|
try {
|
|
25
28
|
const { size } = await promises_1.default.stat(filePath);
|
|
26
29
|
const endIndex = start >= 0 && !end && end !== 0
|
|
@@ -38,10 +41,16 @@ class FilesystemStorage extends common_1.Storage {
|
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
async store(fileName, data) {
|
|
41
|
-
const filePath =
|
|
44
|
+
const filePath = this.resolveFilePath(fileName, 'store');
|
|
45
|
+
if (!filePath) {
|
|
46
|
+
// The stream must still be consumed to prevent request from hanging.
|
|
47
|
+
data.resume();
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
42
50
|
try {
|
|
51
|
+
await this.ensureDirectoryExists(filePath);
|
|
43
52
|
await (0, promises_2.pipeline)(data, node_fs_1.default.createWriteStream(filePath));
|
|
44
|
-
return fileName;
|
|
53
|
+
return (0, common_1.normalizeStoragePath)(fileName) ?? fileName;
|
|
45
54
|
}
|
|
46
55
|
catch (e) {
|
|
47
56
|
common_1.logger.error(e, `Error storing file: ${filePath}`);
|
|
@@ -49,7 +58,10 @@ class FilesystemStorage extends common_1.Storage {
|
|
|
49
58
|
}
|
|
50
59
|
}
|
|
51
60
|
async delete(fileName) {
|
|
52
|
-
const filePath =
|
|
61
|
+
const filePath = this.resolveFilePath(fileName, 'delete');
|
|
62
|
+
if (!filePath) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
53
65
|
try {
|
|
54
66
|
await promises_1.default.unlink(filePath);
|
|
55
67
|
await this.removeEmptyDirectories(node_path_1.default.dirname(filePath));
|
|
@@ -61,14 +73,11 @@ class FilesystemStorage extends common_1.Storage {
|
|
|
61
73
|
}
|
|
62
74
|
}
|
|
63
75
|
async exists(fileName) {
|
|
64
|
-
const filePath =
|
|
65
|
-
|
|
66
|
-
await promises_1.default.access(filePath, node_fs_1.default.constants.F_OK);
|
|
67
|
-
return true;
|
|
68
|
-
}
|
|
69
|
-
catch (e) {
|
|
76
|
+
const filePath = this.resolveFilePath(fileName, 'exists');
|
|
77
|
+
if (!filePath) {
|
|
70
78
|
return false;
|
|
71
79
|
}
|
|
80
|
+
return this.pathExists(filePath);
|
|
72
81
|
}
|
|
73
82
|
async checkHealth() {
|
|
74
83
|
try {
|
|
@@ -80,14 +89,30 @@ class FilesystemStorage extends common_1.Storage {
|
|
|
80
89
|
}
|
|
81
90
|
}
|
|
82
91
|
/** @internal */
|
|
83
|
-
|
|
84
|
-
const filePath =
|
|
85
|
-
if (
|
|
86
|
-
|
|
92
|
+
resolveFilePath(fileName, action) {
|
|
93
|
+
const filePath = (0, common_1.resolveStoragePath)(this._dirPath, fileName);
|
|
94
|
+
if (!filePath) {
|
|
95
|
+
common_1.logger.error({ fileName, action }, 'Rejected invalid storage file path');
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const reservedPath = (0, common_1.findReservedStoragePath)(fileName, common_1.config.STORAGE_RESERVED_PATHS);
|
|
99
|
+
if (reservedPath !== null) {
|
|
100
|
+
common_1.logger.error({ fileName, action, reservedPath }, 'Rejected reserved storage file path');
|
|
101
|
+
return null;
|
|
87
102
|
}
|
|
88
103
|
return filePath;
|
|
89
104
|
}
|
|
90
105
|
/** @internal */
|
|
106
|
+
async pathExists(filePath) {
|
|
107
|
+
try {
|
|
108
|
+
await promises_1.default.access(filePath, node_fs_1.default.constants.F_OK);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** @internal */
|
|
91
116
|
async removeEmptyDirectories(filePath) {
|
|
92
117
|
const normalizedDirPath = node_path_1.default.normalize(filePath);
|
|
93
118
|
if (!normalizedDirPath ||
|
|
@@ -117,10 +142,7 @@ class FilesystemStorage extends common_1.Storage {
|
|
|
117
142
|
/** @internal */
|
|
118
143
|
async ensureDirectoryExists(filePath) {
|
|
119
144
|
const dirname = node_path_1.default.dirname(filePath);
|
|
120
|
-
|
|
121
|
-
await promises_1.default.access(dirname, node_fs_1.default.constants.F_OK);
|
|
122
|
-
}
|
|
123
|
-
catch (e) {
|
|
145
|
+
if (!(await this.pathExists(dirname))) {
|
|
124
146
|
await promises_1.default.mkdir(dirname, { recursive: true });
|
|
125
147
|
}
|
|
126
148
|
}
|
package/utils/file-util.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FilesConfig } from '@appweaver/common';
|
|
1
|
+
import { FilesConfig, ResourceModel } from '@appweaver/common';
|
|
2
2
|
import { File } from '../types';
|
|
3
3
|
/**
|
|
4
4
|
* Builds the access URL for a stored file. The `public` or `protected` path prefix is resolved from the access type
|
|
@@ -55,12 +55,31 @@ export declare function isValidMimeType(mimeType: string, mimeTypeExp?: string |
|
|
|
55
55
|
*/
|
|
56
56
|
export declare function generateFileName(name: string, pattern?: string, variables?: Record<string, any>): string;
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
58
|
+
* Validates that no configured file name pattern writes into a path reserved by `STORAGE_RESERVED_PATHS`. Both the
|
|
59
|
+
* global `STORAGE_NAME_PATTERN` and the `namePattern` of every file field of the provided models are checked. Patterns
|
|
60
|
+
* defined as a factory function cannot be resolved before an upload, so they are validated at runtime instead.
|
|
61
|
+
*
|
|
62
|
+
* @param {Record<string, ResourceModel>} models - The loaded resource models, keyed by model name.
|
|
63
|
+
* @throws {Error} Throws an error naming the model, the field, and the reserved path when a pattern is reserved.
|
|
64
|
+
*/
|
|
65
|
+
export declare function validateFileNamePatterns(models: Record<string, ResourceModel>): void;
|
|
66
|
+
/**
|
|
67
|
+
* Sanitizes a given filename by removing invalid characters that are not allowed in file systems. Path separators are
|
|
68
|
+
* preserved, since a file name may be a path relative to the storage root.
|
|
59
69
|
*
|
|
60
70
|
* @param fileName The original filename to sanitize.
|
|
61
71
|
* @return A sanitized filename with invalid characters removed.
|
|
62
72
|
*/
|
|
63
73
|
export declare function sanitizeFilename(fileName: string): string;
|
|
74
|
+
/**
|
|
75
|
+
* Sanitizes an untrusted value into a single file path segment. In addition to the characters removed by
|
|
76
|
+
* {@link sanitizeFilename}, all path separators are stripped and leading dots are removed, so that the value can never
|
|
77
|
+
* introduce a subdirectory, a `..` traversal or a hidden file.
|
|
78
|
+
*
|
|
79
|
+
* @param value The untrusted value to sanitize.
|
|
80
|
+
* @return A sanitized single path segment, or an empty string when nothing usable remains.
|
|
81
|
+
*/
|
|
82
|
+
export declare function sanitizeFileSegment(value?: string | null): string;
|
|
64
83
|
/**
|
|
65
84
|
* Aggregates the provided files into a structured object based on the given configuration.
|
|
66
85
|
*
|
package/utils/file-util.js
CHANGED
|
@@ -6,7 +6,9 @@ exports.maxFileSize = maxFileSize;
|
|
|
6
6
|
exports.sizeInBytes = sizeInBytes;
|
|
7
7
|
exports.isValidMimeType = isValidMimeType;
|
|
8
8
|
exports.generateFileName = generateFileName;
|
|
9
|
+
exports.validateFileNamePatterns = validateFileNamePatterns;
|
|
9
10
|
exports.sanitizeFilename = sanitizeFilename;
|
|
11
|
+
exports.sanitizeFileSegment = sanitizeFileSegment;
|
|
10
12
|
exports.aggregateFiles = aggregateFiles;
|
|
11
13
|
const common_1 = require("@appweaver/common");
|
|
12
14
|
const context_1 = require("../context");
|
|
@@ -106,25 +108,100 @@ function generateFileName(name, pattern, variables = {}) {
|
|
|
106
108
|
nameWithoutExtension = nameParts.join('.');
|
|
107
109
|
}
|
|
108
110
|
const defaultPattern = common_1.config.STORAGE_NAME_PATTERN ?? '{name}-{hash}.{extension}';
|
|
111
|
+
// Only the pattern itself is trusted to contain directory separators, every substituted value is reduced to a
|
|
112
|
+
// single path segment so that an uploaded file name cannot introduce a sub-path or a traversal.
|
|
109
113
|
let fileName = (0, common_1.replacePatternVariables)(pattern ?? defaultPattern, {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
114
|
+
...sanitizeVariables(variables),
|
|
115
|
+
name: sanitizeFileSegment(nameWithoutExtension),
|
|
116
|
+
extension: sanitizeFileSegment(extension)
|
|
113
117
|
});
|
|
114
118
|
fileName = sanitizeFilename(fileName);
|
|
115
119
|
fileName = fileName.endsWith('.')
|
|
116
120
|
? fileName.substring(0, fileName.length - 1)
|
|
117
121
|
: fileName;
|
|
118
|
-
return fileName ||
|
|
122
|
+
return ((0, common_1.normalizeStoragePath)(fileName) ||
|
|
123
|
+
sanitizeFileSegment(name) ||
|
|
124
|
+
(0, common_1.generateToken)('bytes', 32));
|
|
119
125
|
}
|
|
120
126
|
/**
|
|
121
|
-
*
|
|
127
|
+
* Validates that no configured file name pattern writes into a path reserved by `STORAGE_RESERVED_PATHS`. Both the
|
|
128
|
+
* global `STORAGE_NAME_PATTERN` and the `namePattern` of every file field of the provided models are checked. Patterns
|
|
129
|
+
* defined as a factory function cannot be resolved before an upload, so they are validated at runtime instead.
|
|
130
|
+
*
|
|
131
|
+
* @param {Record<string, ResourceModel>} models - The loaded resource models, keyed by model name.
|
|
132
|
+
* @throws {Error} Throws an error naming the model, the field, and the reserved path when a pattern is reserved.
|
|
133
|
+
*/
|
|
134
|
+
function validateFileNamePatterns(models) {
|
|
135
|
+
const reservedPaths = common_1.config.STORAGE_RESERVED_PATHS;
|
|
136
|
+
const globalReservedPath = (0, common_1.findReservedStoragePath)(common_1.config.STORAGE_NAME_PATTERN, reservedPaths);
|
|
137
|
+
if (globalReservedPath !== null) {
|
|
138
|
+
throw new Error(`Configured STORAGE_NAME_PATTERN '${common_1.config.STORAGE_NAME_PATTERN}' is placed under the reserved ` +
|
|
139
|
+
`storage path '${globalReservedPath}'`);
|
|
140
|
+
}
|
|
141
|
+
for (const model of Object.values(models)) {
|
|
142
|
+
for (const [field, fileConfig] of Object.entries(model.config.files ?? {})) {
|
|
143
|
+
if (!(0, common_1.isString)(fileConfig.namePattern)) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const reservedPath = (0, common_1.findReservedStoragePath)(fileConfig.namePattern, reservedPaths);
|
|
147
|
+
if (reservedPath !== null) {
|
|
148
|
+
throw new Error(`File name pattern '${fileConfig.namePattern}' of the '${model.name}.${field}' field is placed ` +
|
|
149
|
+
`under the reserved storage path '${reservedPath}'`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Sanitizes a given filename by removing invalid characters that are not allowed in file systems. Path separators are
|
|
156
|
+
* preserved, since a file name may be a path relative to the storage root.
|
|
122
157
|
*
|
|
123
158
|
* @param fileName The original filename to sanitize.
|
|
124
159
|
* @return A sanitized filename with invalid characters removed.
|
|
125
160
|
*/
|
|
126
161
|
function sanitizeFilename(fileName) {
|
|
127
|
-
|
|
162
|
+
const invalidFilenameChars = new Set([
|
|
163
|
+
'\\',
|
|
164
|
+
':',
|
|
165
|
+
'*',
|
|
166
|
+
'?',
|
|
167
|
+
'"',
|
|
168
|
+
'<',
|
|
169
|
+
'>',
|
|
170
|
+
'|'
|
|
171
|
+
]);
|
|
172
|
+
return [...fileName]
|
|
173
|
+
.filter((char) => {
|
|
174
|
+
const code = char.charCodeAt(0);
|
|
175
|
+
// Strip control characters (including NUL) and characters invalid in file names.
|
|
176
|
+
return code > 31 && code !== 127 && !invalidFilenameChars.has(char);
|
|
177
|
+
})
|
|
178
|
+
.join('');
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Sanitizes an untrusted value into a single file path segment. In addition to the characters removed by
|
|
182
|
+
* {@link sanitizeFilename}, all path separators are stripped and leading dots are removed, so that the value can never
|
|
183
|
+
* introduce a subdirectory, a `..` traversal or a hidden file.
|
|
184
|
+
*
|
|
185
|
+
* @param value The untrusted value to sanitize.
|
|
186
|
+
* @return A sanitized single path segment, or an empty string when nothing usable remains.
|
|
187
|
+
*/
|
|
188
|
+
function sanitizeFileSegment(value) {
|
|
189
|
+
if (!(0, common_1.isString)(value)) {
|
|
190
|
+
return '';
|
|
191
|
+
}
|
|
192
|
+
// Trimming must precede the leading dot removal, otherwise a padded value such as ' .. ' keeps its dots.
|
|
193
|
+
return sanitizeFilename(value)
|
|
194
|
+
.replace(/[/\\]+/g, '')
|
|
195
|
+
.trim()
|
|
196
|
+
.replace(/^\.+/, '')
|
|
197
|
+
.trim();
|
|
198
|
+
}
|
|
199
|
+
/** @internal */
|
|
200
|
+
function sanitizeVariables(variables) {
|
|
201
|
+
return Object.fromEntries(Object.entries(variables).map(([key, value]) => [
|
|
202
|
+
key,
|
|
203
|
+
(0, common_1.isString)(value) ? sanitizeFileSegment(value) : value
|
|
204
|
+
]));
|
|
128
205
|
}
|
|
129
206
|
/**
|
|
130
207
|
* Aggregates the provided files into a structured object based on the given configuration.
|