@techiev2/vajra 1.4.3 → 1.5.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/README.md +3 -0
- package/_ +0 -5
- package/benchmark.sh +58 -0
- package/examples/api.js +4 -0
- package/index.js +21 -24
- package/package.json +1 -1
- package/tests/tests.js +205 -2
package/README.md
CHANGED
package/_
CHANGED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
const MIMES={html: 'text/html',js: 'application/javascript',css: 'text/css',json: 'application/json',png: 'image/png',jpg: 'image/jpeg',jpeg: 'image/jpeg',gif: 'image/gif',svg: 'image/svg+xml'}
|
|
2
|
-
|
|
3
|
-
res.sendFile = (filePath) => new Promise((resolve) => {
|
|
4
|
-
createReadStream(filePath).on('open', () => { res.setHeader('Content-Type', MIMES[filePath.split('.').pop()?.toLowerCase()] || 'application/octet-stream'); !res.getHeader('Accept-Ranges') && res.setHeader('Accept-Ranges', 'bytes'); }).on('error', (err) => { (err.code === 'ENOENT' ? default_404(req, res, `${filePath.split('/').slice(-1)} not found.`) : default_500(req, res)); resolve() }).pipe(res).on('finish', resolve).on('error', resolve);
|
|
5
|
-
});
|
package/benchmark.sh
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
declare -A portMap=(
|
|
4
|
+
["elysia"]=4000
|
|
5
|
+
["express"]=4001
|
|
6
|
+
["fastify"]=4002
|
|
7
|
+
["hono"]=4003
|
|
8
|
+
["vajra"]=4004
|
|
9
|
+
)
|
|
10
|
+
declare -A runnerMap=(
|
|
11
|
+
["elysia"]="index.js"
|
|
12
|
+
["elysia_node"]="index.js"
|
|
13
|
+
["express"]="index.js"
|
|
14
|
+
["fastify"]="index.js"
|
|
15
|
+
["hono"]="index.js"
|
|
16
|
+
["vajra"]="examples/api.js"
|
|
17
|
+
)
|
|
18
|
+
frameworks=$(ls -d */ | sed 's|/$||' | grep -v auth | grep vajra)
|
|
19
|
+
|
|
20
|
+
LOOPS=5
|
|
21
|
+
|
|
22
|
+
runWrk() {
|
|
23
|
+
port=$1
|
|
24
|
+
sync && echo 3 > tee /proc/sys/vm/drop_caches
|
|
25
|
+
wrk -t16 -c600 -d10s \
|
|
26
|
+
-H "Content-Type: multipart/form-data; boundary=BOUNDARY123456" \
|
|
27
|
+
"http://localhost:${port}/upload" < ../test_upload_2mb.txt
|
|
28
|
+
}
|
|
29
|
+
main() {
|
|
30
|
+
echo "" > report.txt
|
|
31
|
+
for framework in ${frameworks[@]}; do
|
|
32
|
+
cd $framework
|
|
33
|
+
mkdir -p benchmarks
|
|
34
|
+
echo "" > benchmarks/report.txt
|
|
35
|
+
port=${portMap[$framework]}
|
|
36
|
+
runner=${runnerMap[$framework]}
|
|
37
|
+
for i in $(seq 1 $LOOPS); do
|
|
38
|
+
bun $runner &
|
|
39
|
+
sleep 2
|
|
40
|
+
runningPID=$(lsof | grep -P ":$port" | head -n1 | awk '{print $2}')
|
|
41
|
+
echo "$framework running at $runningPID"
|
|
42
|
+
sleep 2
|
|
43
|
+
runWrk $port >> benchmarks/report.txt
|
|
44
|
+
sleep 2
|
|
45
|
+
echo "Killing $framework running at $runningPID"
|
|
46
|
+
kill -9 $runningPID
|
|
47
|
+
done
|
|
48
|
+
avg_rps=$(grep "Requests/sec:" benchmarks/report.txt | awk -F' ' '{print $2}' | awk '{x+=$1; n++} END { print x/n}')
|
|
49
|
+
avg_tps=$(grep "Transfer/sec:" benchmarks/report.txt | awk -F' ' '{print $2}' | awk '{x+=$1; n++} END { print x/n}')
|
|
50
|
+
echo "${framework}
|
|
51
|
+
Average RPS: ${avg_rps}/s
|
|
52
|
+
Average TPS: ${avg_tps}MB/s
|
|
53
|
+
|
|
54
|
+
" >> "../report.txt"
|
|
55
|
+
cd ..
|
|
56
|
+
done
|
|
57
|
+
}
|
|
58
|
+
main
|
package/examples/api.js
CHANGED
|
@@ -51,4 +51,8 @@ get('/web/users', async ({ query }, res) => {
|
|
|
51
51
|
const users = await getUsers(query)
|
|
52
52
|
const headers = Object.keys(users[0]).slice(0, 2)
|
|
53
53
|
return res.html(`users.html`, { users, headers })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
get('/files/:path', async ({ params, params: { path }}, res) => {
|
|
57
|
+
return res.sendFile(`${import.meta.dirname}/${path}`)
|
|
54
58
|
})
|
package/index.js
CHANGED
|
@@ -1,25 +1,16 @@
|
|
|
1
1
|
import { createServer } from 'node:http'
|
|
2
2
|
import { readFile, access } from 'node:fs/promises'
|
|
3
|
-
import {
|
|
3
|
+
import { createReadStream, existsSync } from 'node:fs'
|
|
4
|
+
import { resolve } from 'path'
|
|
4
5
|
|
|
5
6
|
const BLOCK_MATCHER=/{{\s*#\s*(?<grpStart>\w+)\s*}}\s*(?<block>.*?)\s*{{\s*\/\s*(?<grpEnd>\w+)\s*}}/gmsi; const INNER_BLOCK_MATCHER = /{\s*(.*?)\s*}/gmsi
|
|
6
7
|
const LOOP_MATCHER=/({\s*\w+@\s*})/gmis; const ESCAPE = { regex: /[&<>"']/g, map: { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }}
|
|
8
|
+
const MIMES={html: 'text/html; charset=utf-8',js: 'application/javascript; charset=utf-8',css: 'text/css; charset=utf-8',json: 'application/json; charset=utf-8',png: 'image/png',jpg: 'image/jpeg',jpeg: 'image/jpeg',gif: 'image/gif',svg: 'image/svg+xml', webp: 'image/webp', txt: 'text/plain; charset=utf-8'}
|
|
7
9
|
|
|
8
|
-
function default_404({ url, method, isPossibleJSON }, res) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
function default_405({ url, method, isPossibleJSON }, res) {
|
|
13
|
-
const message = `Method ${method} not allowed by route ${url}.`
|
|
14
|
-
res.status(405); return isPossibleJSON ? res.json({ message }) : res.writeMessage(message)
|
|
15
|
-
}
|
|
16
|
-
function default_500({ url, method }, res, error) {
|
|
17
|
-
process.env.DEBUG && console.log({ error })
|
|
18
|
-
res.status(500).writeMessage(process.env.DEBUG ? `${error.stack}` : `Server error.\nRoute: ${url}\nMethod: ${method}\nTimestamp: ${new Date().getTime()}\n`)
|
|
19
|
-
}
|
|
20
|
-
function default_413(res) {
|
|
21
|
-
res.status(413).writeMessage('Payload Too Large')
|
|
22
|
-
}
|
|
10
|
+
function default_404({ url, method, isPossibleJSON }, res, message) { message = message || `Route ${url} not found for method ${method}.`; res.status(404); return isPossibleJSON ? res.json({ message }) : res.writeMessage(message) }
|
|
11
|
+
function default_405({ url, method, isPossibleJSON }, res, message) { message = message || `Method ${method} not allowed by route ${url}.`; res.status(405); return isPossibleJSON ? res.json({ message }) : res.writeMessage(message) }
|
|
12
|
+
function default_500({ url, method }, res, error) { process.env.DEBUG && console.log({ error }); res.status(500).writeMessage(process.env.DEBUG && error?.stack ? `${error.stack}` : `Server error.\nRoute: ${url}\nMethod: ${method}\nTimestamp: ${new Date().getTime()}\n`) }
|
|
13
|
+
function default_413(res) { res.status(413).writeMessage('Payload Too Large') }
|
|
23
14
|
|
|
24
15
|
const MAX_MB = 2; const MAX_FILE_SIZE = MAX_MB * 1024 * 1024
|
|
25
16
|
|
|
@@ -36,13 +27,19 @@ export default class Vajra {
|
|
|
36
27
|
res.sent = false; res.status = (/**@type{code} Number*/ code) => { if (!+code || +code < 100 || +code > 599) { throw new Error(`Invalid status code ${code}`) }; res.statusCode = code; res.statusSet = true; return res }
|
|
37
28
|
res.json = data => {
|
|
38
29
|
if (res.sent) return res; if (!res.statusSet) res.statusCode = 200
|
|
39
|
-
const
|
|
30
|
+
const _data = JSON.stringify(data); res.sent = true; res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Length', Buffer.byteLength(_data)); res.write(_data); res.end(); return res
|
|
40
31
|
}
|
|
41
32
|
res.writeMessage = (message = '') => {
|
|
42
33
|
if (res.sent) { return res }; if (!message) { res.status(500); message = 'Server error'; }
|
|
43
|
-
res.sent = true; res.setHeader('Content-Type', 'text/plain'); res.setHeader('Content-Length', Buffer.
|
|
34
|
+
res.sent = true; res.setHeader('Content-Type', 'text/plain'); res.setHeader('Content-Length', Buffer.byteLength(message)); res.write(message); res.end()
|
|
44
35
|
return res
|
|
45
36
|
}
|
|
37
|
+
res.sendFile = (filePath) => {
|
|
38
|
+
let [filePath_, extension] = filePath.split('.')
|
|
39
|
+
const _filePath = !extension ? filePath : existsSync(filePath) ? filePath : existsSync(`${filePath_}.${extension.toLowerCase()}`) ? `${filePath_}.${extension.toLowerCase()}` : existsSync(`${filePath_}.${extension.toUpperCase()}`) ? `${filePath_}.${extension.toLowerCase()}` : ''
|
|
40
|
+
if (!filePath) { return default_404(req, res, new Error(`File ${filePath} not found`)) }
|
|
41
|
+
return new Promise((resolve) => createReadStream(_filePath).on('open', () => { res.setHeader('Content-Type', MIMES[filePath.split('.').pop()?.toLowerCase()] || 'application/octet-stream'); !res.getHeader('Accept-Ranges') && res.setHeader('Accept-Ranges', 'bytes'); }).on('error', (err) => { (err.code === 'ENOENT' ? default_404(req, res, `${filePath.split('/').slice(-1)} not found.`) : default_500(req, res, err)); resolve() }).pipe(res).on('finish', resolve).on('error', resolve))
|
|
42
|
+
};
|
|
46
43
|
function sanitize(value) { if (value == null) return ''; return String(value).replace(ESCAPE.regex, m => ESCAPE.map[m]); }
|
|
47
44
|
res.html = async (templatePath, data = {}) => {
|
|
48
45
|
templatePath = resolve(templatePath); const appRoot = resolve(process.cwd()); const root = this.#props.viewRoot ? resolve(this.#props.viewRoot) : appRoot;
|
|
@@ -70,19 +67,19 @@ export default class Vajra {
|
|
|
70
67
|
const formDataMatcher = /Content-Disposition: form-data; name=['"](?<name>[^"']+)['"]\s+(?<value>.*?)$/smi;
|
|
71
68
|
let boundaryMatch = (req.headers['Content-Type'] || '').match(/boundary=(.*)/); const boundary = boundaryMatch ? '--' + boundaryMatch[1] : null; const fileDataMatcher = /^Content-Disposition:.*?name=["'](?<field>[^"']+)["'].*?filename=["'](?<fileName>[^"']+)["'].*?Content-Type:\s*(?<contentType>[^\r\n]*)\r?\n\r?\n(?<content>[\s\S]*)$/ims
|
|
72
69
|
req.on('end', async () => {
|
|
73
|
-
req.files = []; if (boundary) {
|
|
70
|
+
req.files = []; if (boundary) {
|
|
71
|
+
req.rawData.split(boundary).filter(Boolean).map((line) => {
|
|
74
72
|
let key, value; if (line.includes('filename')) { req.files.push(fileDataMatcher.exec(line)?.groups || {}); return }
|
|
75
|
-
[key, value] = Object.values(line.match(formDataMatcher)?.groups || {}); (key && value) && Object.assign(req.formData, { [key]: value });
|
|
73
|
+
[key, value] = Object.values(line.match(formDataMatcher)?.groups || {}); (key && value) && Object.assign(req.formData, { [key]: value }); return
|
|
76
74
|
})
|
|
77
75
|
}
|
|
78
76
|
if (Object.keys(req.formData).length) { req.body = req.formData } else {
|
|
79
77
|
try { req.body = JSON.parse(req.rawData); req.isPossibleJSON = true } catch (_) { req.body = Object.fromEntries(req.rawData.split('&').map((pair) => pair.split('='))) }
|
|
80
78
|
}; setImmediate(runMiddlwares)
|
|
81
|
-
})
|
|
82
|
-
req.cookies = Object.fromEntries((req.headers.Cookie || req.headers.cookie || '').split(/;\s*/).map((k) => k.split('=')).map(([k, v]) => [k.trim(), decodeURIComponent(v).trim()]))
|
|
79
|
+
}); req.cookies = Object.fromEntries((req.headers.Cookie || req.headers.cookie || '').split(/;\s*/).map((k) => k.split('=')).map(([k, v]) => [k.trim(), decodeURIComponent(v).trim()]))
|
|
83
80
|
})
|
|
84
81
|
async function handleRoute() {
|
|
85
|
-
let _url = req.url.split('?')[0]; if (_url.endsWith('/')) _url = _url.split('/').slice(0, -1).join('/')
|
|
82
|
+
req.url = decodeURIComponent(req.url); let _url = req.url.split('?')[0]; if (_url.endsWith('/')) _url = _url.split('/').slice(0, -1).join('/')
|
|
86
83
|
let match_; const directHandler = (Vajra.#straightRoutes[_url] || Vajra.#straightRoutes[`${_url}/`] || {})[req.method.toLowerCase()]
|
|
87
84
|
if (directHandler) { try { await directHandler(req, res); if (!res.sent && !res.writableEnded) res.end() } catch (error) { return default_500(req, res, error) }; return }
|
|
88
85
|
Object.entries(Vajra.#routes).map(([route, handler]) => {
|
|
@@ -97,7 +94,7 @@ export default class Vajra {
|
|
|
97
94
|
function start({ port, host = '127.0.0.1' }, cb) { Vajra.#app.listen(port, () => { console.log(`App listening at http://${host}:${port}`); if (typeof cb === 'function') { cb() } }); return defaults }
|
|
98
95
|
function register(method, path, handler) {
|
|
99
96
|
const paramMatcher = /.*?(?<param>\:[a-zA-Z]{1,})/g; let pathMatcherStr = path
|
|
100
|
-
path.matchAll(paramMatcher).forEach(match => pathMatcherStr = pathMatcherStr.replace(match.groups.param, `{0,1}(?<${match.groups.param.slice(1)}>[\\w
|
|
97
|
+
path.matchAll(paramMatcher).forEach(match => pathMatcherStr = pathMatcherStr.replace(match.groups.param, `{0,1}(?<${match.groups.param.slice(1)}>[\\w|\\/|\\.|\\s|\\-]+)`))
|
|
101
98
|
if (path !== '/' && pathMatcherStr.endsWith('/')) { pathMatcherStr = pathMatcherStr.replace(/(\/)$/, '/?') }
|
|
102
99
|
if (!paramMatcher.exec(path)?.groups) { Vajra.#straightRoutes[pathMatcherStr] = Object.assign(Vajra.#straightRoutes[pathMatcherStr] || {}, { [method]: handler }); return }
|
|
103
100
|
Vajra.#routes[pathMatcherStr] = Object.assign(Vajra.#routes[pathMatcherStr] || {}, {[method]: handler}); return defaults
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@techiev2/vajra",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Blazing-fast, zero-dependency Node.js server with routing, middleware, multipart uploads, and templating. 111 lines · ~95k req/s · ~52 MB idle.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"http-server",
|
package/tests/tests.js
CHANGED
|
@@ -2,6 +2,8 @@ import assert from 'node:assert';
|
|
|
2
2
|
import { encode } from 'node:querystring';
|
|
3
3
|
import { afterEach, beforeEach, suite, test } from 'node:test';
|
|
4
4
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
5
|
+
import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
6
|
+
import path, { resolve } from 'node:path';
|
|
5
7
|
|
|
6
8
|
import pkg from '../package.json' with {type: 'json'}
|
|
7
9
|
import { sign, verify } from '../libs/auth/jwt.js';
|
|
@@ -17,9 +19,9 @@ async function getJSON(url, method = 'GET', body) {
|
|
|
17
19
|
return (await getResponse(url, method, body)).json()
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
const BASE_URL = 'http://localhost:4002'
|
|
20
23
|
|
|
21
24
|
suite('Test HTTP API at port 4002', () => {
|
|
22
|
-
const BASE_URL = 'http://localhost:4002'
|
|
23
25
|
suite('Test HTTP GET', () => {
|
|
24
26
|
test('Basic HTTP GET to respond with a now and empty query/params', async () => {
|
|
25
27
|
assert.deepEqual((await (await getResponse(BASE_URL)).text()), 'Hello from Vajra ⚡')
|
|
@@ -35,6 +37,48 @@ suite('Test HTTP API at port 4002', () => {
|
|
|
35
37
|
assert.deepEqual(res_query, query)
|
|
36
38
|
assert.deepEqual(res_params, {})
|
|
37
39
|
})
|
|
40
|
+
test('Plain ../ traversal should be normalized by URL parser and result in 404 (safe)', async () => {
|
|
41
|
+
const url = `${BASE_URL}/files/../../../../etc/passwd`
|
|
42
|
+
const res = await getResponse(url)
|
|
43
|
+
// It should NOT succeed (200) and typically 404 because file doesn't exist inside app root
|
|
44
|
+
assert.notStrictEqual(res.status, 200)
|
|
45
|
+
assert.strictEqual(res.status, 404)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('URL-encoded ../ (%2e%2e) should be decoded BEFORE normalization, still resulting in safe 404', async () => {
|
|
49
|
+
const url = `${BASE_URL}/files/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd`
|
|
50
|
+
const res = await getResponse(url)
|
|
51
|
+
assert.notStrictEqual(res.status, 200)
|
|
52
|
+
// Expected: pathname becomes /etc/passwd → safe 404
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('Double-encoded traversal should also be normalized safely', async () => {
|
|
56
|
+
const url = `${BASE_URL}/files/%252e%252e/%252e%252e/%252e%252e/%252e%252e/etc/passwd`
|
|
57
|
+
const res = await getResponse(url)
|
|
58
|
+
assert.notStrictEqual(res.status, 200)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('Attempt with null byte (old exploit) should not crash and return error', async () => {
|
|
62
|
+
// Note: modern Node.js rejects %00 in URLs early, often with parse error
|
|
63
|
+
const url = `${BASE_URL}/files/test_files/hello.txt%00../../etc/passwd`
|
|
64
|
+
const res = await getResponse(url)
|
|
65
|
+
assert.notStrictEqual(res.status, 200)
|
|
66
|
+
// Likely 400 or 404 depending on framework
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('Absolute path attempt should be treated as relative (inside app root) and 404', async () => {
|
|
70
|
+
const url = `${BASE_URL}/files//etc/passwd` // leading // becomes /
|
|
71
|
+
const res = await getResponse(url)
|
|
72
|
+
assert.notStrictEqual(res.status, 200)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('Very deep normalized path (many ../ collapsing to root) should still be safe', async () => {
|
|
76
|
+
const deep = '../'.repeat(50) + 'hello.txt'
|
|
77
|
+
const url = `${BASE_URL}/files/${deep}`
|
|
78
|
+
const res = await getResponse(url)
|
|
79
|
+
// After normalization: /hello.txt → tries to open hello.txt in app root → 404 (unless exists)
|
|
80
|
+
assert.strictEqual(res.status, 404) // or whatever your non-existent handler returns
|
|
81
|
+
})
|
|
38
82
|
})
|
|
39
83
|
suite('Test HTTP POST', () => {
|
|
40
84
|
test('Basic HTTP POST to respond with a now and body', async () => {
|
|
@@ -299,4 +343,163 @@ suite('Tests for library functions', () => {
|
|
|
299
343
|
})
|
|
300
344
|
});
|
|
301
345
|
|
|
302
|
-
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
suite('res.sendFile(path)', async () => {
|
|
350
|
+
|
|
351
|
+
const TEST_DIR = path.resolve(`${import.meta.dirname}/../examples/test_files`);
|
|
352
|
+
const textContent = 'Hello from Vajra!\nThis is a test file.'
|
|
353
|
+
const textContentBuffer = new Uint8Array(Buffer.from(textContent))
|
|
354
|
+
const minimalJpeg = Buffer.from([
|
|
355
|
+
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01,
|
|
356
|
+
0x01, 0x01, 0x00, 0x48, 0x00, 0x48, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43,
|
|
357
|
+
0x00, 0x03, 0x02, 0x02, 0x03, 0x02, 0x02, 0x03, 0x03, 0x03, 0x03, 0x04,
|
|
358
|
+
0x03, 0x03, 0x04, 0x05, 0x08, 0x05, 0x05, 0x04, 0x04, 0x05, 0x0a, 0x07,
|
|
359
|
+
0x07, 0x06, 0x08, 0x0c, 0x0a, 0x0c, 0x0c, 0x0b, 0x0a, 0x0b, 0x0b, 0x0d,
|
|
360
|
+
0x0e, 0x12, 0x10, 0x0d, 0x0e, 0x11, 0x0e, 0x0b, 0x0b, 0x10, 0x16, 0x10,
|
|
361
|
+
0x11, 0x13, 0x14, 0x15, 0x15, 0x15, 0x0c, 0x0f, 0x17, 0x18, 0x16, 0x14,
|
|
362
|
+
0x18, 0x12, 0x14, 0x15, 0x14, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01,
|
|
363
|
+
0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x14, 0x00, 0x01,
|
|
364
|
+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
365
|
+
0x00, 0x00, 0x00, 0x08, 0xff, 0xc4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00,
|
|
366
|
+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
367
|
+
0x00, 0x00, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x01, 0x3f, 0x00,
|
|
368
|
+
0xd2, 0xcf, 0x20, 0xff, 0xd9
|
|
369
|
+
]);
|
|
370
|
+
beforeEach(async () => {
|
|
371
|
+
await mkdir(TEST_DIR, { recursive: true });
|
|
372
|
+
await writeFile(`${TEST_DIR}/test.txt`, textContentBuffer)
|
|
373
|
+
await writeFile(`${TEST_DIR}/test.TXT`, textContentBuffer)
|
|
374
|
+
await writeFile(path.join(TEST_DIR, 'test.jpg'), minimalJpeg);
|
|
375
|
+
await writeFile(path.join(TEST_DIR, 'test.JPG'), minimalJpeg);
|
|
376
|
+
await writeFile(`${TEST_DIR}/unknown.ext`, new Uint8Array(Buffer.from('some data')))
|
|
377
|
+
});
|
|
378
|
+
afterEach(async () => {
|
|
379
|
+
await rm(TEST_DIR, { recursive: true, force: true });
|
|
380
|
+
});
|
|
381
|
+
test('On GET at /files/test.txt, server should return the file contents', async () => {
|
|
382
|
+
const filePath = `test_files/test.txt`
|
|
383
|
+
const url = `${BASE_URL}/files/${filePath}`
|
|
384
|
+
const apiResponseText = await (await getResponse(url)).text()
|
|
385
|
+
const filetext = (await readFile(resolve(`${import.meta.dirname}/../examples/${filePath}`))).toString()
|
|
386
|
+
assert.strictEqual(apiResponseText, filetext)
|
|
387
|
+
})
|
|
388
|
+
test('On GET at /files/random.txt, server should return the file contents', async () => {
|
|
389
|
+
const id = randomUUID()
|
|
390
|
+
const filePath = `test_files/${id}`
|
|
391
|
+
const url = `${BASE_URL}/files/${filePath}`
|
|
392
|
+
const res = await getResponse(url)
|
|
393
|
+
const json = await res.json()
|
|
394
|
+
assert.strictEqual(res.status, 404)
|
|
395
|
+
assert.strictEqual(json.message, `${id} not found.`)
|
|
396
|
+
})
|
|
397
|
+
test('On GET at /files/test.txt, server should return the file contents with correct Content-Type', async () => {
|
|
398
|
+
const filePath = 'test_files/test.txt'
|
|
399
|
+
const url = `${BASE_URL}/files/${filePath}`
|
|
400
|
+
const res = await getResponse(url)
|
|
401
|
+
|
|
402
|
+
assert.strictEqual(res.status, 200)
|
|
403
|
+
assert.strictEqual(res.headers.get('content-type'), 'text/plain; charset=utf-8')
|
|
404
|
+
assert.strictEqual(res.headers.get('accept-ranges'), 'bytes')
|
|
405
|
+
|
|
406
|
+
const apiResponseText = await res.text()
|
|
407
|
+
assert.strictEqual(apiResponseText.trim(), textContent.trim())
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
test('On GET at /files/test.png, server should return the image with correct Content-Type and exact bytes', async () => {
|
|
411
|
+
const filePath = 'test_files/test.jpg'
|
|
412
|
+
const url = `${BASE_URL}/files/${filePath}`
|
|
413
|
+
const res = await getResponse(url)
|
|
414
|
+
|
|
415
|
+
assert.strictEqual(res.status, 200)
|
|
416
|
+
assert.strictEqual(res.headers.get('content-type'), 'image/jpeg')
|
|
417
|
+
assert.strictEqual(res.headers.get('accept-ranges'), 'bytes')
|
|
418
|
+
|
|
419
|
+
const buffer = Buffer.from(await res.arrayBuffer())
|
|
420
|
+
const fileData = await readFile(resolve(`${import.meta.dirname}/../examples/test_files/test.jpg`))
|
|
421
|
+
assert.strictEqual(buffer.length, fileData.length)
|
|
422
|
+
assert.ok(buffer.equals(fileData))
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
test('On GET at non-existent random file, server should return 404 with filename in message', async () => {
|
|
426
|
+
const id = randomUUID()
|
|
427
|
+
const filePath = `test_files/${id}.txt`
|
|
428
|
+
const url = `${BASE_URL}/files/${filePath}`
|
|
429
|
+
const res = await getResponse(url)
|
|
430
|
+
const json = await res.json()
|
|
431
|
+
|
|
432
|
+
assert.strictEqual(res.status, 404)
|
|
433
|
+
assert.strictEqual(json.message, `${id}.txt not found.`)
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
test('On GET at file with unknown extension, server should use application/octet-stream', async () => {
|
|
437
|
+
const unknownFileName = 'unknown.ext'
|
|
438
|
+
await writeFile(path.join(TEST_DIR, unknownFileName), 'some data')
|
|
439
|
+
|
|
440
|
+
const filePath = `test_files/${unknownFileName}`
|
|
441
|
+
const url = `${BASE_URL}/files/${filePath}`
|
|
442
|
+
const res = await getResponse(url)
|
|
443
|
+
|
|
444
|
+
assert.strictEqual(res.status, 200)
|
|
445
|
+
assert.strictEqual(res.headers.get('content-type'), 'application/octet-stream')
|
|
446
|
+
assert.strictEqual(await res.text(), 'some data')
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
test('Path traversal with ../ should not allow access outside test_files directory', async () => {
|
|
450
|
+
const url = `${BASE_URL}/files/../examples/test_files/test.txt`
|
|
451
|
+
const res = await getResponse(url)
|
|
452
|
+
assert.notStrictEqual(res.status, 200) // Should be 404 (or 400/403 if sanitized better)
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
test('Path traversal with encoded %2e%2e should be blocked', async () => {
|
|
456
|
+
const url = `${BASE_URL}/files/%2e%2e/%2e%2e/examples/test_files/test.txt`
|
|
457
|
+
const res = await getResponse(url)
|
|
458
|
+
assert.notStrictEqual(res.status, 200)
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
test('Path traversal with null byte %00 should be rejected (if not already handled by framework)', async () => {
|
|
462
|
+
const url = `${BASE_URL}/files/test_files/test.txt%00../../etc/passwd`
|
|
463
|
+
const res = await getResponse(url)
|
|
464
|
+
assert.notStrictEqual(res.status, 200)
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
test('Request for directory (trailing slash) should not serve or list contents', async () => {
|
|
468
|
+
const url = `${BASE_URL}/files/test_files/`
|
|
469
|
+
const res = await getResponse(url)
|
|
470
|
+
assert.notStrictEqual(res.status, 200) // Expect 404 or 400, no directory listing
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
test('File with multiple extensions should use MIME based on final extension', async () => {
|
|
474
|
+
const multiFile = 'archive.tar.gz'
|
|
475
|
+
await writeFile(path.join(TEST_DIR, multiFile), 'gzipped tar')
|
|
476
|
+
|
|
477
|
+
const filePath = `test_files/${multiFile}`
|
|
478
|
+
const url = `${BASE_URL}/files/${filePath}`
|
|
479
|
+
const res = await getResponse(url)
|
|
480
|
+
|
|
481
|
+
assert.strictEqual(res.status, 200)
|
|
482
|
+
const ct = res.headers.get('content-type')
|
|
483
|
+
assert.ok(ct.includes('gzip') || ct.includes('octet-stream'))
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
test('Case-insensitive extension handling', async () => {
|
|
487
|
+
const upperFile = 'test.JPG'
|
|
488
|
+
// await writeFile(path.join(TEST_DIR, upperFile), imageContent)
|
|
489
|
+
const filePath = `test_files/${upperFile}`
|
|
490
|
+
const url = `${BASE_URL}/files/${filePath}`
|
|
491
|
+
const res = await getResponse(url)
|
|
492
|
+
assert.strictEqual(res.status, 200)
|
|
493
|
+
assert.strictEqual(res.headers.get('content-type'), 'image/jpeg')
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
test('Query parameters should be ignored and file still served correctly', async () => {
|
|
497
|
+
const url = `${BASE_URL}/files/test_files/test.txt?cache_bust=123`
|
|
498
|
+
const res = await getResponse(url)
|
|
499
|
+
|
|
500
|
+
assert.strictEqual(res.status, 200)
|
|
501
|
+
const text = await res.text()
|
|
502
|
+
assert.strictEqual(text.trim(), textContent.trim())
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
});
|