@flancer32/teq-web 0.1.0 → 0.3.0
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/.github/workflows/npm-publish.yml +1 -1
- package/AGENTS.md +101 -0
- package/CHANGELOG.md +33 -0
- package/README.md +41 -2
- package/package.json +4 -2
- package/src/AGENTS.md +108 -0
- package/src/Back/Dto/Handler/Source.js +46 -0
- package/src/Back/Handler/Static/A/Config.js +58 -0
- package/src/Back/Handler/Static/A/Fallback.js +39 -0
- package/src/Back/Handler/Static/A/FileService.js +69 -0
- package/src/Back/Handler/Static/A/Registry.js +52 -0
- package/src/Back/Handler/Static/A/Resolver.js +83 -0
- package/src/Back/Handler/Static.js +25 -101
- package/src/Back/Helper/Cast.js +28 -0
- package/src/Back/Helper/Respond.js +77 -0
- package/test/accept/ExternalServer.test.mjs +66 -0
- package/test/accept/Server.test.mjs +99 -1
- package/test/dev/app/Plugin/Start.js +5 -3
- package/test/unit/AGENTS.md +106 -0
- package/test/unit/Back/Dispatcher.test.mjs +150 -0
- package/test/unit/Back/Dto/Handler/Source.test.mjs +40 -0
- package/test/unit/Back/Handler/Pre/Log.test.mjs +22 -0
- package/test/unit/Back/Handler/Static/A/Config.test.mjs +52 -0
- package/test/unit/Back/Handler/Static/A/Fallback.test.mjs +60 -0
- package/test/unit/Back/Handler/Static/A/FileService.test.mjs +225 -0
- package/test/unit/Back/Handler/Static/A/Registry.test.mjs +83 -0
- package/test/unit/Back/Handler/Static/A/Resolver.test.mjs +73 -0
- package/test/unit/Back/Handler/Static/Static.test.mjs +235 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { buildTestContainer } from '../../../../common.js';
|
|
4
|
+
|
|
5
|
+
describe('Fl32_Web_Back_Handler_Static_A_Resolver', () => {
|
|
6
|
+
it('resolves allowed path', async () => {
|
|
7
|
+
const container = buildTestContainer();
|
|
8
|
+
/** @type {Fl32_Web_Back_Handler_Static_A_Resolver} */
|
|
9
|
+
const resolver = await container.get('Fl32_Web_Back_Handler_Static_A_Resolver$');
|
|
10
|
+
|
|
11
|
+
const { resolve } = await import('node:path');
|
|
12
|
+
const config = {
|
|
13
|
+
root: resolve('/root'),
|
|
14
|
+
prefix: '/p/',
|
|
15
|
+
allow: { pkg: ['a.txt'] }
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const fsPath = resolver.resolve(config, 'pkg/a.txt');
|
|
19
|
+
assert.strictEqual(fsPath, resolve('/root/pkg/a.txt'));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns null for disallowed path', async () => {
|
|
23
|
+
const container = buildTestContainer();
|
|
24
|
+
/** @type {Fl32_Web_Back_Handler_Static_A_Resolver} */
|
|
25
|
+
const resolver = await container.get('Fl32_Web_Back_Handler_Static_A_Resolver$');
|
|
26
|
+
|
|
27
|
+
const { resolve } = await import('node:path');
|
|
28
|
+
const config = {
|
|
29
|
+
root: resolve('/root'),
|
|
30
|
+
prefix: '/p/',
|
|
31
|
+
allow: { pkg: ['a.txt'] }
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const result = resolver.resolve(config, 'pkg/b.txt');
|
|
35
|
+
assert.strictEqual(result, null);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('throws on path traversal attempts', async () => {
|
|
39
|
+
const container = buildTestContainer();
|
|
40
|
+
/** @type {Fl32_Web_Back_Handler_Static_A_Resolver} */
|
|
41
|
+
const resolver = await container.get('Fl32_Web_Back_Handler_Static_A_Resolver$');
|
|
42
|
+
|
|
43
|
+
const { resolve } = await import('node:path');
|
|
44
|
+
const config = {
|
|
45
|
+
root: resolve('/root'),
|
|
46
|
+
prefix: '/p/',
|
|
47
|
+
allow: { pkg: ['a.txt'] }
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
assert.throws(
|
|
51
|
+
() => resolver.resolve(config, '../x'),
|
|
52
|
+
/Static access denied/
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('throws on absolute rel paths', async () => {
|
|
57
|
+
const container = buildTestContainer();
|
|
58
|
+
/** @type {Fl32_Web_Back_Handler_Static_A_Resolver} */
|
|
59
|
+
const resolver = await container.get('Fl32_Web_Back_Handler_Static_A_Resolver$');
|
|
60
|
+
|
|
61
|
+
const { resolve } = await import('node:path');
|
|
62
|
+
const config = {
|
|
63
|
+
root: resolve('/root'),
|
|
64
|
+
prefix: '/p/',
|
|
65
|
+
allow: { pkg: ['a.txt'] }
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
assert.throws(
|
|
69
|
+
() => resolver.resolve(config, '/etc/passwd'),
|
|
70
|
+
/Static access denied/
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import {describe, it, beforeEach} from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {EventEmitter} from 'node:events';
|
|
4
|
+
import {buildTestContainer} from '../../../common.js';
|
|
5
|
+
|
|
6
|
+
/** Simple HTTP/2 constants mock */
|
|
7
|
+
const mockHttp2 = {
|
|
8
|
+
constants: {
|
|
9
|
+
HTTP2_HEADER_CONTENT_LENGTH: 'content-length',
|
|
10
|
+
HTTP2_HEADER_CONTENT_TYPE: 'content-type',
|
|
11
|
+
HTTP2_HEADER_LAST_MODIFIED: 'last-modified',
|
|
12
|
+
HTTP_STATUS_OK: 200
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Minimal path mock for FS key normalization */
|
|
17
|
+
const mockPath = {
|
|
18
|
+
resolve: (...parts) => parts.join('/').replace(/\/+/g, '/'),
|
|
19
|
+
join: (...parts) => parts.join('/').replace(/\/+/g, '/'),
|
|
20
|
+
isAbsolute: p => p.startsWith('/'),
|
|
21
|
+
extname: p => {
|
|
22
|
+
const m = p.match(/(\.[^./]+)$/);
|
|
23
|
+
return m ? m[1] : '';
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** In-memory FS storage */
|
|
28
|
+
let storage;
|
|
29
|
+
let mockFs;
|
|
30
|
+
|
|
31
|
+
function resetFs() {
|
|
32
|
+
storage = new Map();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function addFile(p, content) {
|
|
36
|
+
const key = mockPath.resolve(p);
|
|
37
|
+
storage.set(key, {
|
|
38
|
+
isFile: () => true,
|
|
39
|
+
isDirectory: () => false,
|
|
40
|
+
size: Buffer.byteLength(content),
|
|
41
|
+
mtime: new Date(),
|
|
42
|
+
content
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function addDir(p) {
|
|
47
|
+
const key = mockPath.resolve(p);
|
|
48
|
+
storage.set(key, {
|
|
49
|
+
isFile: () => false,
|
|
50
|
+
isDirectory: () => true,
|
|
51
|
+
size: 0,
|
|
52
|
+
mtime: new Date(),
|
|
53
|
+
content: null
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
resetFs();
|
|
59
|
+
mockFs = {
|
|
60
|
+
promises: {
|
|
61
|
+
stat: async p => {
|
|
62
|
+
const key = mockPath.resolve(p);
|
|
63
|
+
if (!storage.has(key)) throw new Error('ENOENT');
|
|
64
|
+
const entry = storage.get(key);
|
|
65
|
+
return {
|
|
66
|
+
isFile: entry.isFile,
|
|
67
|
+
isDirectory: entry.isDirectory,
|
|
68
|
+
size: entry.size,
|
|
69
|
+
mtime: entry.mtime
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
createReadStream: p => ({
|
|
74
|
+
pipe: res => {
|
|
75
|
+
setImmediate(() => {
|
|
76
|
+
const entry = storage.get(mockPath.resolve(p));
|
|
77
|
+
if (entry && entry.content != null) res.write(entry.content);
|
|
78
|
+
res.end();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/** Mock response with writable stream semantics */
|
|
86
|
+
class MockRes extends EventEmitter {
|
|
87
|
+
constructor() {
|
|
88
|
+
super();
|
|
89
|
+
this.data = Buffer.alloc(0);
|
|
90
|
+
this.statusCode = undefined;
|
|
91
|
+
this.headers = undefined;
|
|
92
|
+
this._sent = false;
|
|
93
|
+
this._ended = false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get headersSent() { return this._sent; }
|
|
97
|
+
|
|
98
|
+
get writableEnded() { return this._ended; }
|
|
99
|
+
|
|
100
|
+
writeHead(status, headers) {
|
|
101
|
+
this.statusCode = status;
|
|
102
|
+
this.headers = headers;
|
|
103
|
+
this._sent = true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
write(chunk) {
|
|
107
|
+
this.data = Buffer.concat([this.data, Buffer.from(chunk)]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
end(chunk) {
|
|
111
|
+
if (chunk) this.write(chunk);
|
|
112
|
+
this._ended = true;
|
|
113
|
+
this.emit('finish');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe('Fl32_Web_Back_Handler_Static', () => {
|
|
118
|
+
let container;
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
container = buildTestContainer();
|
|
122
|
+
container.register('node:fs', mockFs);
|
|
123
|
+
container.register('node:http2', mockHttp2);
|
|
124
|
+
container.register('node:path', mockPath);
|
|
125
|
+
container.register('Fl32_Web_Back_Logger$', {
|
|
126
|
+
warn: () => {},
|
|
127
|
+
exception: () => {}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('serves from the most specific source', async () => {
|
|
132
|
+
addDir('/a');
|
|
133
|
+
addFile('/a/test.txt', 'A');
|
|
134
|
+
addDir('/b');
|
|
135
|
+
addFile('/b/test.txt', 'B');
|
|
136
|
+
|
|
137
|
+
/** @type {Fl32_Web_Back_Dto_Handler_Source} */
|
|
138
|
+
const dtoSource = await container.get('Fl32_Web_Back_Dto_Handler_Source$');
|
|
139
|
+
/** @type {Fl32_Web_Back_Handler_Static} */
|
|
140
|
+
const handler = await container.get('Fl32_Web_Back_Handler_Static$');
|
|
141
|
+
|
|
142
|
+
await handler.init({
|
|
143
|
+
sources: [
|
|
144
|
+
dtoSource.create({prefix: '/files/', root: '/a', allow: {'.': ['.']}, defaults: []}),
|
|
145
|
+
dtoSource.create({prefix: '/files/special/', root: '/b', allow: {'.': ['.']}, defaults: []})
|
|
146
|
+
]
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const res = new MockRes();
|
|
150
|
+
const ok = await handler.handle({url: '/files/special/test.txt'}, res);
|
|
151
|
+
await new Promise(r => res.on('finish', r));
|
|
152
|
+
|
|
153
|
+
assert.strictEqual(ok, true);
|
|
154
|
+
assert.strictEqual(res.data.toString(), 'B');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('enforces allow-list rules', async () => {
|
|
158
|
+
addFile('src/Back/Server.js', 'class X {}');
|
|
159
|
+
addFile('src/Back/Handler/Static.js', 'ignore');
|
|
160
|
+
|
|
161
|
+
/** @type {Fl32_Web_Back_Dto_Handler_Source} */
|
|
162
|
+
const dtoSource = await container.get('Fl32_Web_Back_Dto_Handler_Source$');
|
|
163
|
+
/** @type {Fl32_Web_Back_Handler_Static} */
|
|
164
|
+
const handler = await container.get('Fl32_Web_Back_Handler_Static$');
|
|
165
|
+
|
|
166
|
+
await handler.init({
|
|
167
|
+
sources: [
|
|
168
|
+
dtoSource.create({prefix: '/s/', root: 'src', allow: {Back: ['Server.js']}, defaults: []})
|
|
169
|
+
]
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const okRes = new MockRes();
|
|
173
|
+
const ok = await handler.handle({url: '/s/Back/Server.js'}, okRes);
|
|
174
|
+
await new Promise(r => okRes.on('finish', r));
|
|
175
|
+
assert.strictEqual(ok, true);
|
|
176
|
+
|
|
177
|
+
const badRes = new MockRes();
|
|
178
|
+
const bad = await handler.handle({url: '/s/Back/Handler/Static.js'}, badRes);
|
|
179
|
+
assert.strictEqual(bad, false);
|
|
180
|
+
assert.strictEqual(badRes.headersSent, false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('serves index files in directories', async () => {
|
|
184
|
+
addDir('/dir');
|
|
185
|
+
addDir('/dir/d');
|
|
186
|
+
addFile('/dir/d/index.txt', 'INDEX');
|
|
187
|
+
|
|
188
|
+
/** @type {Fl32_Web_Back_Dto_Handler_Source} */
|
|
189
|
+
const dtoSource = await container.get('Fl32_Web_Back_Dto_Handler_Source$');
|
|
190
|
+
/** @type {Fl32_Web_Back_Handler_Static} */
|
|
191
|
+
const handler = await container.get('Fl32_Web_Back_Handler_Static$');
|
|
192
|
+
|
|
193
|
+
await handler.init({
|
|
194
|
+
sources: [
|
|
195
|
+
dtoSource.create({
|
|
196
|
+
prefix: '/w/',
|
|
197
|
+
root: '/dir',
|
|
198
|
+
allow: {'.': ['.']},
|
|
199
|
+
defaults: ['index.txt']
|
|
200
|
+
})
|
|
201
|
+
]
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const res = new MockRes();
|
|
205
|
+
const ok = await handler.handle({url: '/w/d/'}, res);
|
|
206
|
+
await new Promise(r => res.on('finish', r));
|
|
207
|
+
|
|
208
|
+
assert.strictEqual(ok, true);
|
|
209
|
+
assert.strictEqual(res.data.toString(), 'INDEX');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('rejects traversal and unmatched prefixes', async () => {
|
|
213
|
+
addFile('/safe/file.txt', 'ok');
|
|
214
|
+
|
|
215
|
+
/** @type {Fl32_Web_Back_Dto_Handler_Source} */
|
|
216
|
+
const dtoSource = await container.get('Fl32_Web_Back_Dto_Handler_Source$');
|
|
217
|
+
/** @type {Fl32_Web_Back_Handler_Static} */
|
|
218
|
+
const handler = await container.get('Fl32_Web_Back_Handler_Static$');
|
|
219
|
+
|
|
220
|
+
await handler.init({
|
|
221
|
+
sources: [
|
|
222
|
+
dtoSource.create({prefix: '/p/', root: '/safe', allow: {'.': ['.']}, defaults: []})
|
|
223
|
+
]
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const res1 = new MockRes();
|
|
227
|
+
const bad1 = await handler.handle({url: '/p/../file.txt'}, res1);
|
|
228
|
+
assert.strictEqual(bad1, false);
|
|
229
|
+
|
|
230
|
+
const res2 = new MockRes();
|
|
231
|
+
const bad2 = await handler.handle({url: '/x/file.txt'}, res2);
|
|
232
|
+
assert.strictEqual(bad2, false);
|
|
233
|
+
assert.strictEqual(res2.headersSent, false);
|
|
234
|
+
});
|
|
235
|
+
});
|