@aria-framework/kit 0.1.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 +81 -0
- package/fileSniff.js +117 -0
- package/index.js +25 -0
- package/likeEscape.js +13 -0
- package/package.json +19 -0
- package/personName.js +32 -0
- package/safeReturnTo.js +35 -0
- package/trackingId.js +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# @aria-framework/kit
|
|
2
|
+
|
|
3
|
+
Aria App Framework — kit module. Five small, dependency-free server utilities
|
|
4
|
+
(Node builtins only, plain CommonJS, no build step).
|
|
5
|
+
|
|
6
|
+
```js
|
|
7
|
+
const { safeReturnTo, escapeLike, fullName, splitName, fileSniff, trackingId }
|
|
8
|
+
= require('@aria-framework/kit');
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## safeReturnTo — open-redirect guard
|
|
12
|
+
|
|
13
|
+
Validates a post-login `returnTo` path. Only on-site paths survive (absolute,
|
|
14
|
+
single leading slash, no scheme/host, no `//` protocol-relative trick);
|
|
15
|
+
anything else collapses to the fallback.
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
safeReturnTo(req.query.returnTo, { fallback: '/dashboard' });
|
|
19
|
+
safeReturnTo(rt, { fallback: '/', denyPrefix: '/portal' }); // also keep users out of auth pages
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## escapeLike — SQL LIKE escaping
|
|
23
|
+
|
|
24
|
+
Escapes `%`, `_` and `\` so user input can sit inside a LIKE pattern. Pair
|
|
25
|
+
with `ESCAPE '\'` in the query:
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
db.prepare("... WHERE name LIKE ? ESCAPE '\\'").all(`%${escapeLike(q)}%`);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## fileSniff — magic-byte upload validation
|
|
32
|
+
|
|
33
|
+
The client-declared Content-Type is attacker-controlled; this checks the
|
|
34
|
+
file's actual leading bytes against the declared MIME's **container family**
|
|
35
|
+
(docx/xlsx are both ZIP, doc/xls both OLE — exact subtypes can't be told apart
|
|
36
|
+
by magic, so families are the honest granularity).
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
if (!fileSniff.matches(file.path, file.mimetype)) reject(file);
|
|
40
|
+
fileSniff.sniff(path); // → 'image/png' | 'zip' | 'ole' | 'isobmff' | 'text' | ... | null
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Covers png/jpeg/gif/webp, pdf, text/csv, office (old + OOXML), heic/heif,
|
|
44
|
+
mp4/mov/webm/avi/ogg, mp3/m4a/wav, zip/7z/rar/gzip.
|
|
45
|
+
|
|
46
|
+
## trackingId — customer-facing reference codes
|
|
47
|
+
|
|
48
|
+
`AB12-CD34-EF56` — Crockford base32 (no I, L, O, U: these codes get read
|
|
49
|
+
aloud and retyped), `crypto.randomInt` for unbiased unguessable picks,
|
|
50
|
+
60 bits of space.
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
trackingId.generate(); // random, no collision check
|
|
54
|
+
trackingId.generateUnique((id) => // guaranteed unique per YOUR storage
|
|
55
|
+
db.prepare('SELECT 1 FROM tickets WHERE tracking_id = ?').get(id));
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`generateUnique` is storage-agnostic — pass any `(id) => truthy-if-exists`
|
|
59
|
+
check (SQL, ORM, HTTP, in-memory). Throws after `maxAttempts` (default 10).
|
|
60
|
+
|
|
61
|
+
## fullName / splitName — person-name convention helpers
|
|
62
|
+
|
|
63
|
+
For the first_name + last_name model where `display_name`/`name` is a
|
|
64
|
+
**maintained composite** (never hand-written):
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
fullName('Ann', 'Bee'); // 'Ann Bee' (trims, drops blanks)
|
|
68
|
+
splitName('Johan van der Merwe') // { first_name: 'Johan van der', last_name: 'Merwe' }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`splitName` splits on the **last** space (last word = surname). Compound
|
|
72
|
+
surnames split imperfectly by design. If an app backfills a split in SQL,
|
|
73
|
+
implement the same last-space rule and keep them in step. Also exported
|
|
74
|
+
namespaced as `personName.{fullName,splitName}`.
|
|
75
|
+
|
|
76
|
+
## Changelog
|
|
77
|
+
|
|
78
|
+
- **0.1.0** — first release. Extracted from Support101 `lib/` (safeReturnTo,
|
|
79
|
+
likeEscape, fileSniff, trackingId, personName). One API change vs the app
|
|
80
|
+
originals: `trackingId.generateUnique` takes an `isTaken(id)` callback
|
|
81
|
+
instead of a better-sqlite3 handle + hardcoded tickets table.
|
package/fileSniff.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magic-byte file-type validation for uploads.
|
|
3
|
+
*
|
|
4
|
+
* multer's `file.mimetype` is the client-declared Content-Type — attacker
|
|
5
|
+
* controlled. This module reads the actual leading bytes and checks they are
|
|
6
|
+
* consistent with the declared MIME's container family, so an HTML file
|
|
7
|
+
* labelled `image/png` is rejected. Office formats can't be distinguished from
|
|
8
|
+
* each other by magic alone (docx/xlsx are both ZIP; doc/xls are both OLE), so
|
|
9
|
+
* we validate the container family, not the exact subtype.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
|
|
14
|
+
/** Sniff the container/type from the first bytes. Returns a coarse tag or null. */
|
|
15
|
+
function sniff(filePath) {
|
|
16
|
+
let fd;
|
|
17
|
+
try {
|
|
18
|
+
fd = fs.openSync(filePath, 'r');
|
|
19
|
+
const buf = Buffer.alloc(16);
|
|
20
|
+
const n = fs.readSync(fd, buf, 0, 16, 0);
|
|
21
|
+
const b = buf.subarray(0, n);
|
|
22
|
+
const hex = b.toString('hex').toUpperCase();
|
|
23
|
+
|
|
24
|
+
if (hex.startsWith('89504E47')) return 'image/png';
|
|
25
|
+
if (hex.startsWith('FFD8FF')) return 'image/jpeg';
|
|
26
|
+
if (hex.startsWith('47494638')) return 'image/gif';
|
|
27
|
+
if (hex.startsWith('25504446')) return 'application/pdf';
|
|
28
|
+
|
|
29
|
+
// RIFF container — disambiguate by the form-type at bytes 8–12.
|
|
30
|
+
if (hex.startsWith('52494646') && n >= 12) {
|
|
31
|
+
const form = b.subarray(8, 12).toString('ascii');
|
|
32
|
+
if (form === 'WEBP') return 'image/webp';
|
|
33
|
+
if (form === 'WAVE') return 'riff-wave';
|
|
34
|
+
if (form === 'AVI ') return 'riff-avi';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ISO-BMFF (MP4/MOV/M4A/HEIC): 'ftyp' box at bytes 4–8. We don't split the
|
|
38
|
+
// brand — validate the container family, like we do for zip-based formats.
|
|
39
|
+
if (n >= 8 && b.subarray(4, 8).toString('ascii') === 'ftyp') return 'isobmff';
|
|
40
|
+
// Matroska/WebM (EBML)
|
|
41
|
+
if (hex.startsWith('1A45DFA3')) return 'ebml';
|
|
42
|
+
// Ogg (audio/video)
|
|
43
|
+
if (hex.startsWith('4F676753')) return 'ogg';
|
|
44
|
+
// MP3: ID3 tag, or an MPEG audio frame sync (0xFFEx).
|
|
45
|
+
if (hex.startsWith('494433') || (n >= 2 && b[0] === 0xFF && (b[1] & 0xE0) === 0xE0)) return 'mp3';
|
|
46
|
+
|
|
47
|
+
// ZIP container (docx/xlsx and plain zip): PK\x03\x04 / PK\x05\x06 / PK\x07\x08
|
|
48
|
+
if (hex.startsWith('504B0304') || hex.startsWith('504B0506') || hex.startsWith('504B0708')) return 'zip';
|
|
49
|
+
// OLE compound file (legacy doc/xls)
|
|
50
|
+
if (hex.startsWith('D0CF11E0A1B11AE1')) return 'ole';
|
|
51
|
+
// Other archives
|
|
52
|
+
if (hex.startsWith('377ABCAF271C')) return '7z';
|
|
53
|
+
if (hex.startsWith('526172211A07')) return 'rar'; // 'Rar!\x1A\x07'
|
|
54
|
+
if (hex.startsWith('1F8B')) return 'gzip';
|
|
55
|
+
// No binary signature: treat as text only if there are no NUL bytes.
|
|
56
|
+
if (n > 0 && !b.includes(0)) return 'text';
|
|
57
|
+
return null;
|
|
58
|
+
} catch (_) {
|
|
59
|
+
return null;
|
|
60
|
+
} finally {
|
|
61
|
+
if (fd !== undefined) { try { fs.closeSync(fd); } catch (_) {} }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Declared MIME → the sniffed tags that are acceptable for it.
|
|
66
|
+
const FAMILY = {
|
|
67
|
+
'image/png': ['image/png'],
|
|
68
|
+
'image/jpeg': ['image/jpeg'],
|
|
69
|
+
'image/gif': ['image/gif'],
|
|
70
|
+
'image/webp': ['image/webp'],
|
|
71
|
+
'application/pdf': ['application/pdf'],
|
|
72
|
+
'text/plain': ['text'],
|
|
73
|
+
'text/csv': ['text'],
|
|
74
|
+
'application/msword': ['ole'],
|
|
75
|
+
'application/vnd.ms-excel': ['ole'],
|
|
76
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['zip'],
|
|
77
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['zip'],
|
|
78
|
+
// images (HEIC/HEIF are ISO-BMFF)
|
|
79
|
+
'image/heic': ['isobmff'],
|
|
80
|
+
'image/heif': ['isobmff'],
|
|
81
|
+
// video
|
|
82
|
+
'video/mp4': ['isobmff'],
|
|
83
|
+
'video/quicktime': ['isobmff'],
|
|
84
|
+
'video/webm': ['ebml'],
|
|
85
|
+
'video/x-msvideo': ['riff-avi'],
|
|
86
|
+
'video/ogg': ['ogg'],
|
|
87
|
+
// audio
|
|
88
|
+
'audio/mpeg': ['mp3'],
|
|
89
|
+
'audio/mp4': ['isobmff'],
|
|
90
|
+
'audio/x-m4a': ['isobmff'],
|
|
91
|
+
'audio/wav': ['riff-wave'],
|
|
92
|
+
'audio/x-wav': ['riff-wave'],
|
|
93
|
+
'audio/ogg': ['ogg'],
|
|
94
|
+
'audio/webm': ['ebml'],
|
|
95
|
+
// archives
|
|
96
|
+
'application/zip': ['zip'],
|
|
97
|
+
'application/x-zip-compressed': ['zip'],
|
|
98
|
+
'application/x-7z-compressed': ['7z'],
|
|
99
|
+
'application/x-rar-compressed': ['rar'],
|
|
100
|
+
'application/vnd.rar': ['rar'],
|
|
101
|
+
'application/gzip': ['gzip']
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* True if the file's actual content is consistent with the declared MIME.
|
|
106
|
+
* @param {string} filePath
|
|
107
|
+
* @param {string} declaredMime
|
|
108
|
+
* @returns {boolean}
|
|
109
|
+
*/
|
|
110
|
+
function matches(filePath, declaredMime) {
|
|
111
|
+
const allowed = FAMILY[declaredMime];
|
|
112
|
+
if (!allowed) return false;
|
|
113
|
+
const sniffed = sniff(filePath);
|
|
114
|
+
return !!sniffed && allowed.includes(sniffed);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { sniff, matches };
|
package/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @aria-framework/kit — public API.
|
|
3
|
+
*
|
|
4
|
+
* Single-function modules are exported flat; multi-function modules keep a
|
|
5
|
+
* namespace (matching how call sites naturally read):
|
|
6
|
+
*
|
|
7
|
+
* safeReturnTo(returnTo, {fallback, denyPrefix}) — open-redirect guard
|
|
8
|
+
* escapeLike(s) — SQL LIKE escaping
|
|
9
|
+
* fullName(first, last) / splitName(full) — person-name helpers
|
|
10
|
+
* fileSniff.sniff(path) / fileSniff.matches(path, mime)
|
|
11
|
+
* trackingId.generate() / trackingId.generateUnique(isTaken, maxAttempts)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { escapeLike } = require('./likeEscape');
|
|
15
|
+
const { fullName, splitName } = require('./personName');
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
safeReturnTo: require('./safeReturnTo'),
|
|
19
|
+
escapeLike,
|
|
20
|
+
fullName,
|
|
21
|
+
splitName,
|
|
22
|
+
fileSniff: require('./fileSniff'),
|
|
23
|
+
trackingId: require('./trackingId'),
|
|
24
|
+
personName: { fullName, splitName }
|
|
25
|
+
};
|
package/likeEscape.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escape a user-supplied string for safe use inside a SQL LIKE pattern.
|
|
3
|
+
* Without this, a search for "100%" or "_" would match far more than intended
|
|
4
|
+
* (a full-table scan / wrong results). Pair with `ESCAPE '\'` in the query.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} s
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
function escapeLike(s) {
|
|
10
|
+
return String(s == null ? '' : s).replace(/[%_\\]/g, '\\$&');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = { escapeLike };
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aria-framework/kit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Aria App Framework — kit module. Small dependency-free server utilities: open-redirect guard (safeReturnTo), SQL LIKE escaping, magic-byte upload validation (fileSniff), Crockford base32 tracking IDs, and person-name compose/split helpers.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"private": false,
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"main": "index.js",
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"safeReturnTo.js",
|
|
14
|
+
"likeEscape.js",
|
|
15
|
+
"fileSniff.js",
|
|
16
|
+
"trackingId.js",
|
|
17
|
+
"personName.js"
|
|
18
|
+
]
|
|
19
|
+
}
|
package/personName.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical person-name helpers — one source of truth for composing a full
|
|
3
|
+
* display name from first + last, and for splitting a full name back into the
|
|
4
|
+
* two parts. Use these everywhere a first_name/last_name pair meets a
|
|
5
|
+
* display_name/full-name field, so the composite convention can't drift.
|
|
6
|
+
*
|
|
7
|
+
* If an app backfills a split in SQL (one-time migration), implement the SAME
|
|
8
|
+
* last-space split there and keep it in step with splitName(). Separate
|
|
9
|
+
* runtime bundles (e.g. a React Native app) need their own copy — keep those
|
|
10
|
+
* in step too.
|
|
11
|
+
*/
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
/** Compose a full/display name from first + last (trimmed; blank parts dropped). */
|
|
15
|
+
function fullName(first, last) {
|
|
16
|
+
return [first, last].map(v => String(v || '').trim()).filter(Boolean).join(' ');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Split a full name on the LAST space: the last word is the surname, everything
|
|
21
|
+
* before it is the first name(s). No space → the whole value is the first name.
|
|
22
|
+
* Compound surnames split imperfectly by design ("Johan van der Merwe"
|
|
23
|
+
* → first "Johan van der" / last "Merwe").
|
|
24
|
+
*/
|
|
25
|
+
function splitName(full) {
|
|
26
|
+
const s = String(full || '').trim();
|
|
27
|
+
const i = s.lastIndexOf(' ');
|
|
28
|
+
if (i === -1) return { first_name: s, last_name: '' };
|
|
29
|
+
return { first_name: s.slice(0, i).trim(), last_name: s.slice(i + 1).trim() };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { fullName, splitName };
|
package/safeReturnTo.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate a post-login returnTo path against open-redirect abuse.
|
|
3
|
+
*
|
|
4
|
+
* A safe path is absolute, single-slash, and on-site (no scheme/host, no `//`
|
|
5
|
+
* protocol-relative prefix). Anything else collapses to `fallback`. Keep the
|
|
6
|
+
* parsing/normalisation in ONE place — a hardening fix here covers every login
|
|
7
|
+
* flow. Typical usage divergence is just data:
|
|
8
|
+
* - staff login: safeReturnTo(rt, { fallback: '/dashboard' })
|
|
9
|
+
* - portal login: safeReturnTo(rt, { fallback: '/', denyPrefix: '/portal' })
|
|
10
|
+
*
|
|
11
|
+
* @param {string} returnTo
|
|
12
|
+
* @param {{fallback?: string, denyPrefix?: string}} [opts]
|
|
13
|
+
* @returns {string} a safe on-site path, or the fallback
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Sentinel base host for relative-URL parsing; any input that resolves to a
|
|
17
|
+
// DIFFERENT host smuggled in its own authority and is rejected.
|
|
18
|
+
const SENTINEL = '_aria_kit_local';
|
|
19
|
+
|
|
20
|
+
function safeReturnTo(returnTo, opts = {}) {
|
|
21
|
+
const fallback = opts.fallback || '/';
|
|
22
|
+
if (typeof returnTo !== 'string' || !returnTo) return fallback;
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(returnTo, `http://${SENTINEL}`);
|
|
25
|
+
if (url.host !== SENTINEL) return fallback;
|
|
26
|
+
if (!url.pathname.startsWith('/') || url.pathname.startsWith('//')) return fallback;
|
|
27
|
+
// Never bounce a freshly-logged-in user back into the auth pages.
|
|
28
|
+
if (opts.denyPrefix && url.pathname.startsWith(opts.denyPrefix)) return fallback;
|
|
29
|
+
return url.pathname + url.search;
|
|
30
|
+
} catch {
|
|
31
|
+
return fallback;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = safeReturnTo;
|
package/trackingId.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public tracking-ID generator (tickets, orders, quotes — any customer-facing
|
|
3
|
+
* reference).
|
|
4
|
+
*
|
|
5
|
+
* Format: three groups of 4 Crockford base32 characters, e.g. "AB12-CD34-EF56".
|
|
6
|
+
* Crockford base32 omits I, L, O, U to avoid visual/voice ambiguity — these
|
|
7
|
+
* IDs get read aloud and retyped by customers. crypto.randomInt gives an
|
|
8
|
+
* unbiased pick from the 32-char alphabet.
|
|
9
|
+
*
|
|
10
|
+
* 12 chars × 5 bits = 60 bits of space, so collisions are astronomically rare;
|
|
11
|
+
* generateUnique still checks on insert to make uniqueness a guarantee, not a
|
|
12
|
+
* probability.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
|
|
17
|
+
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; // Crockford base32 (no I L O U)
|
|
18
|
+
|
|
19
|
+
function group() {
|
|
20
|
+
let s = '';
|
|
21
|
+
for (let i = 0; i < 4; i++) s += ALPHABET[crypto.randomInt(ALPHABET.length)];
|
|
22
|
+
return s;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Generate a random tracking ID (no collision check). */
|
|
26
|
+
function generate() {
|
|
27
|
+
return `${group()}-${group()}-${group()}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Generate a tracking ID guaranteed unique per the caller's own storage.
|
|
32
|
+
* Storage-agnostic: the caller supplies the existence check.
|
|
33
|
+
*
|
|
34
|
+
* trackingId.generateUnique((id) =>
|
|
35
|
+
* db.prepare('SELECT 1 FROM tickets WHERE tracking_id = ?').get(id));
|
|
36
|
+
*
|
|
37
|
+
* @param {(id: string) => any} isTaken - truthy if the ID already exists
|
|
38
|
+
* @param {number} [maxAttempts=10]
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
function generateUnique(isTaken, maxAttempts = 10) {
|
|
42
|
+
if (typeof isTaken !== 'function') {
|
|
43
|
+
throw new Error('generateUnique(isTaken): isTaken must be a function (id) => truthy-if-exists');
|
|
44
|
+
}
|
|
45
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
46
|
+
const id = generate();
|
|
47
|
+
if (!isTaken(id)) return id;
|
|
48
|
+
}
|
|
49
|
+
throw new Error('Could not generate a unique tracking ID');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { generate, generateUnique };
|