Security audit and readme #27
@@ -27,6 +27,12 @@ npm install
|
|||||||
node index.js
|
node index.js
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you're updating an existing install (older `node_modules`), the admin DB backup/restore feature now uses `multer`, so make sure it's installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install multer
|
||||||
|
```
|
||||||
|
|
||||||
Add this to your `.env` (**required**: `MONGO_URI`, `JWT_SECRET`):
|
Add this to your `.env` (**required**: `MONGO_URI`, `JWT_SECRET`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Generated
+50
-18
@@ -94,13 +94,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.7.9",
|
"version": "1.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||||
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
|
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.6",
|
||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.4",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -155,9 +155,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -473,6 +473,21 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@@ -601,13 +616,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
"integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==",
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
"mime-types": "^2.1.12"
|
"mime-types": "^2.1.12"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -740,6 +757,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
@@ -877,23 +909,23 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jwa": {
|
"node_modules/jwa": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||||
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
|
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer-equal-constant-time": "1.0.1",
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
"ecdsa-sig-formatter": "1.0.11",
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jws": {
|
"node_modules/jws": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
|
||||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
"integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jwa": "^1.4.1",
|
"jwa": "^1.4.2",
|
||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+41
-1
@@ -6,6 +6,7 @@ const path = require("path");
|
|||||||
const zlib = require("zlib");
|
const zlib = require("zlib");
|
||||||
const readline = require("readline");
|
const readline = require("readline");
|
||||||
const multer = require("multer");
|
const multer = require("multer");
|
||||||
|
const { Transform } = require("stream");
|
||||||
|
|
||||||
// Use MongoDB Extended JSON to preserve ObjectId/Date/etc.
|
// Use MongoDB Extended JSON to preserve ObjectId/Date/etc.
|
||||||
// bson is a transitive dependency of mongoose (mongodb driver).
|
// bson is a transitive dependency of mongoose (mongodb driver).
|
||||||
@@ -20,6 +21,26 @@ const router = express.Router();
|
|||||||
|
|
||||||
const BACKUP_DIR = path.join(process.cwd(), "backups");
|
const BACKUP_DIR = path.join(process.cwd(), "backups");
|
||||||
const MAX_UPLOAD_BYTES = 250 * 1024 * 1024; // 250MB
|
const MAX_UPLOAD_BYTES = 250 * 1024 * 1024; // 250MB
|
||||||
|
// SECURITY: protect against gzip "zip bombs" (small upload -> massive output).
|
||||||
|
// This caps the *decompressed* stream size during restore.
|
||||||
|
const MAX_RESTORE_UNZIPPED_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||||
|
|
||||||
|
function createByteLimitStream(maxBytes) {
|
||||||
|
let seen = 0;
|
||||||
|
return new Transform({
|
||||||
|
transform(chunk, _enc, cb) {
|
||||||
|
seen += chunk.length;
|
||||||
|
if (seen > maxBytes) {
|
||||||
|
const err = new Error("Restore failed: decompressed data is too large");
|
||||||
|
// mimic our handler's error shape
|
||||||
|
err.statusCode = 413;
|
||||||
|
cb(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cb(null, chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function requireAdmin(req) {
|
function requireAdmin(req) {
|
||||||
const token = req.headers.authorization?.split(" ")[1];
|
const token = req.headers.authorization?.split(" ")[1];
|
||||||
@@ -251,12 +272,28 @@ router.post("/db/restore", upload.single("backup"), async (req, res) => {
|
|||||||
return res.status(400).json({ message: "No backup file uploaded" });
|
return res.status(400).json({ message: "No backup file uploaded" });
|
||||||
}
|
}
|
||||||
uploadedPath = file.path;
|
uploadedPath = file.path;
|
||||||
|
// Best-effort: refuse obviously wrong file names (UI expects .ndjson.gz).
|
||||||
|
const original = String(file.originalname || "").trim().toLowerCase();
|
||||||
|
if (original && !original.endsWith(".gz")) {
|
||||||
|
const err = new Error("Invalid backup file (expected a .gz file)");
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const db = mongoose.connection.db;
|
const db = mongoose.connection.db;
|
||||||
const gunzip = zlib.createGunzip();
|
const gunzip = zlib.createGunzip();
|
||||||
const input = fs.createReadStream(uploadedPath).pipe(gunzip);
|
const byteLimit = createByteLimitStream(MAX_RESTORE_UNZIPPED_BYTES);
|
||||||
|
const input = fs.createReadStream(uploadedPath).pipe(gunzip).pipe(byteLimit);
|
||||||
|
|
||||||
const rl = readline.createInterface({ input, crlfDelay: Infinity });
|
const rl = readline.createInterface({ input, crlfDelay: Infinity });
|
||||||
|
// Ensure stream errors don't become unhandled.
|
||||||
|
let streamError = null;
|
||||||
|
input.on("error", (e) => {
|
||||||
|
streamError = e;
|
||||||
|
try {
|
||||||
|
rl.close();
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
let meta = null;
|
let meta = null;
|
||||||
let currentCollection = null;
|
let currentCollection = null;
|
||||||
@@ -347,6 +384,9 @@ router.post("/db/restore", upload.single("backup"), async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await flushBatch();
|
await flushBatch();
|
||||||
|
if (streamError) {
|
||||||
|
throw streamError;
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup upload after successful restore
|
// Cleanup upload after successful restore
|
||||||
try {
|
try {
|
||||||
|
|||||||
+17
-3
@@ -169,7 +169,7 @@ router.get("/stats", verifyToken, async (req, res) => {
|
|||||||
|
|
||||||
// Register User
|
// Register User
|
||||||
router.post("/register", async (req, res) => {
|
router.post("/register", async (req, res) => {
|
||||||
const { name, email, password, role, verifoof } = req.body;
|
const { name, email, password, verifoof } = req.body;
|
||||||
const dogCheckEnabled = woof.length > 0;
|
const dogCheckEnabled = woof.length > 0;
|
||||||
|
|
||||||
function registerVerification() {
|
function registerVerification() {
|
||||||
@@ -179,6 +179,19 @@ router.post("/register", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const nameNorm = typeof name === "string" ? name.trim() : "";
|
||||||
|
const emailNorm = typeof email === "string" ? email.trim() : "";
|
||||||
|
|
||||||
|
if (!nameNorm) {
|
||||||
|
throw new Error("Le nom est obligatoire");
|
||||||
|
}
|
||||||
|
if (!emailNorm) {
|
||||||
|
throw new Error("L'email est obligatoire");
|
||||||
|
}
|
||||||
|
// Basic sanity check (not a full RFC validator)
|
||||||
|
if (!/^\S+@\S+\.\S+$/.test(emailNorm)) {
|
||||||
|
throw new Error("Email invalide.");
|
||||||
|
}
|
||||||
if (!password) {
|
if (!password) {
|
||||||
throw new Error("Le mot de passe est obligatoire et ne peut être indéfini");
|
throw new Error("Le mot de passe est obligatoire et ne peut être indéfini");
|
||||||
}
|
}
|
||||||
@@ -189,14 +202,15 @@ router.post("/register", async (req, res) => {
|
|||||||
return res.status(400).json({ message: "Mauvais nom de chien" });
|
return res.status(400).json({ message: "Mauvais nom de chien" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await User.findOne({ email });
|
const existingUser = await User.findOne({ email: emailNorm });
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return res.status(409).json({ message: "L'email existe déjà, veuillez en choisir un autre." });
|
return res.status(409).json({ message: "L'email existe déjà, veuillez en choisir un autre." });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash password and create user
|
// Hash password and create user
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
const user = new User({ name, email, password: hashedPassword, role });
|
// SECURITY: never accept role escalation from a public endpoint.
|
||||||
|
const user = new User({ name: nameNorm, email: emailNorm, password: hashedPassword, role: 0 });
|
||||||
await user.save();
|
await user.save();
|
||||||
|
|
||||||
res.status(201).json({ message: "L'utilisateur s'est enregistré avec succès !" });
|
res.status(201).json({ message: "L'utilisateur s'est enregistré avec succès !" });
|
||||||
|
|||||||
Reference in New Issue
Block a user