Added security policies to all endpoints and implemented file upload feature on posts

This commit is contained in:
Sven laptop
2026-07-24 23:25:31 +02:00
parent 9519a01ca0
commit 9e56f00c1e
19 changed files with 718 additions and 134 deletions
+1
View File
@@ -1,2 +1,3 @@
/node_modules /node_modules
.env .env
/uploads
+40
View File
@@ -0,0 +1,40 @@
# API contract (agent reference)
Base URL: `http://localhost:${PORT}`. Use `Authorization: Bearer <token>` on every endpoint except register/login. JSON uses `Content-Type: application/json`; upload routes use `multipart/form-data`.
| Method + path | Auth | Input | Result / policy |
|---|---:|---|---|
| `POST /auth/register` | no | JSON: `name`, `username`, `email`, `password` | `201 {id}`; strict fields; see password policy below |
| `POST /auth/login` | no | JSON: `email`, `password` | `200 {id,token,expires_at}`; `401` is generic |
| `GET /posts` | yes | no body/query | `200` all posts; ownership does not limit viewing |
| `GET /posts/me` | yes | no body/query | `200` only posts with `author_id = token.user_id` |
| `GET /posts/image/:filename` | yes | no body/query | Authenticated image download |
| `POST /posts/create` | yes | multipart fields: `title`, `text`; optional file `image` | `201` post; `author_id` always comes from token |
| `PUT /posts/edit?id=<id>` | yes + owner | multipart: optional `title`, `text`, `image`, `remove_image=true\|false` | `200` updated post; upload replaces image; `remove_image=true` clears it; image + remove is invalid |
| `DELETE /posts/delete?id=<id>` | yes + owner | query: `id` only | `200`; deletes only matching `id AND author_id` and removes stored image |
| `POST /create_relationship?me=<id>&them=<id>` | yes | query: `me`, `them` only | `200`; `me` must equal token user ID |
## Input rules
- Any undocumented body or query field returns `400`; do not send `author_id` to create posts.
- Registration: `name` 1100 chars; `username` 150, no whitespace; `email` 1254, trimmed/lowercased, valid format, no whitespace, not disposable; `password` 1128 and at least 12 chars with lower/upper/number/special, no username/email-local-part, common password, triple repeat, or obvious sequence.
- Login accepts only `email` and `password`; email is trimmed/lowercased; password is not trimmed or otherwise transformed (ordinary whitespace is significant).
- Passwords, hashes, salts, JWTs, and database details must never be logged or exposed.
## Uploads
```sh
curl -X POST http://localhost:3000/posts/create \
-H "Authorization: Bearer $TOKEN" \
-F title='Hello' -F text='Body' -F image=@photo.png
```
To edit text/title, replace the image, or remove it:
```sh
curl -X PUT "http://localhost:3000/posts/edit?id=12" \
-H "Authorization: Bearer $TOKEN" \
-F text='Updated body' -F remove_image=true
```
Omit `image` and `remove_image` to keep the current image. Stored image URLs require the same Bearer token.
+48
View File
@@ -0,0 +1,48 @@
# Filing Cabinet API
Node.js/Express API for accounts, JWT-authenticated actions, relationships, and posts.
## Run
```sh
npm install
npm run dev # nodemon app.js
```
Configure `.env` (never commit it):
```env
PORT=3000
JWT_SECRET=<random secret, at least 32 characters>
JWT_EXPIRES_IN=86400
UPLOAD_DIR=./uploads
MAX_UPLOAD_SIZE_BYTES=5242880
```
Database and graph-database variables are also required by the relevant routes. See `.env.example`.
## Authentication
`POST /auth/register` and `POST /auth/login` are public. Every other route requires:
```http
Authorization: Bearer <JWT>
```
Login returns `{ id, token, expires_at }`. The token is HS256-signed and contains `user_id`, `iat`, and `exp`. Never log or expose tokens, passwords, password hashes, or salts.
## API
The compact endpoint contract is in [API.md](API.md). Unknown body/query fields are rejected with `400`; clients must send only documented fields.
## Security and ownership
- The API is the authoritative validator; client validation is only UX.
- Registration/login validation and password rules are defined in [AUTH_INPUT_POLICY.md](AUTH_INPUT_POLICY.md).
- Post creation always uses `author_id` from the verified JWT; clients must not send `author_id`.
- Post edit/delete require that the JWT user owns the post.
- `/posts` is visible to any authenticated user; `/posts/me` filters by JWT `user_id`.
- Relationship creation requires `me` to equal the JWT user ID.
- Uploads accept only JPEG, PNG, GIF, and WebP, one file named `image`, up to `MAX_UPLOAD_SIZE_BYTES` (default 5 MiB). Files receive random names and are stored under `UPLOAD_DIR`.
- Registration rejects disposable email addresses and weak/reused-pattern passwords. Login failures use the generic `Invalid email or password` response.
- Do not expose database errors or stack traces to clients.
+30 -25
View File
@@ -1,40 +1,45 @@
const express = require('express'); const express = require("express");
const cors = require('cors'); const cors = require("cors");
const app = express(); const app = express();
const dotenv = require('dotenv'); const dotenv = require("dotenv");
require('dotenv').config(); const { authenticateToken } = require("./middleware/authenticate_token");
require("dotenv").config();
const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS || '') const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS || "")
.split(',') .split(",")
.map((origin) => origin.trim()) .map((origin) => origin.trim())
.filter(Boolean); .filter(Boolean);
// Apply configured CORS origins to every endpoint, including preflight requests. // Apply configured CORS origins to every endpoint, including preflight requests.
app.use(cors({ origin: allowedOrigins })); app.use(cors({ origin: allowedOrigins }));
app.use(express.json()); app.use(express.json());
// Data routes
const getDataRoute = require('./features/data/GET_data');
const postDataRoute = require('./features/data/POST_data');
const deleteDataRoute = require('./features/data/DELETE_data');
app.use('/get_data', getDataRoute);
app.use('/post_data', postDataRoute);
app.use('/delete_data', deleteDataRoute);
// People routes // People routes
const CreatePersonRoute = require('./features/people/REGISTER_people'); const CreatePersonRoute = require("./features/people/REGISTER_people");
const LoginPersonRoute = require('./features/people/LOGIN_people'); const LoginPersonRoute = require("./features/people/LOGIN_people");
app.use('/auth/register', CreatePersonRoute); app.use("/auth/register", CreatePersonRoute);
app.use('/auth/login', LoginPersonRoute); app.use("/auth/login", LoginPersonRoute);
// Every route below this point requires a valid JWT. Registration and login stay public.
app.use(authenticateToken);
// Relation routes // Relation routes
const CreateRelationRoute = require('./features/relations/CREATE_friendship'); const CreateRelationRoute = require("./features/relations/CREATE_friendship");
app.use('/create_relationship', CreateRelationRoute) app.use("/create_relationship", CreateRelationRoute);
// Post routes
const CreatePostRoute = require("./features/posts/CREATE_post");
const DeletePostRoute = require("./features/posts/DELETE_post");
const GetPostsRoute = require("./features/posts/GET_posts");
const EditPostRoute = require("./features/posts/EDIT_post");
app.use("/posts", GetPostsRoute);
app.use("/posts/create", CreatePostRoute);
app.use("/posts/delete", DeletePostRoute);
app.use("/posts/edit", EditPostRoute);
app.listen(process.env.PORT, () => { app.listen(process.env.PORT, () => {
console.log(`Server draait op port ${process.env.PORT}`) console.log(`Server draait op port ${process.env.PORT}`);
}) });
-29
View File
@@ -1,29 +0,0 @@
const express = require('express');
const router = express.Router();
const pool = require('../../db');
async function delete_data(req, res) {
try {
const name = req.query.name;
const result = await pool.query('DELETE FROM data WHERE name = $1', [name]);
res.status(200).json({
message: 'Data Deleted',
deleted: result.rows
})
}
catch (err) {
console.error('database query failed', {
message: err.message,
code: err.code,
detail: err.detail,
hint: err.hint,
stack: err.stack,
});
res.status(500).send('Data has not been deleted, request failed');
}
};
router.delete('/', delete_data);
module.exports = router;
-26
View File
@@ -1,26 +0,0 @@
const express = require('express');
const router = express.Router();
const pool = require('../../db');
async function get_data(req, res) {
try {
const result = await pool.query('SELECT id, name, created_at FROM data');
console.log(result.rows);
res.json(result.rows);
}
catch (err) {
console.error('[get_data] database query failed', {
message: err.message,
code: err.code,
detail: err.detail,
hint: err.hint,
stack: err.stack,
});
res.status(500).send('No data available, request failed');
}
};
router.get('/', get_data);
module.exports = router;
-27
View File
@@ -1,27 +0,0 @@
const express = require('express');
const router = express.Router();
const pool = require('../../db');
async function post_data(req, res) {
try {
const { name } = req.body;
const { rows } = await pool.query('INSERT INTO data (name) VALUES ($1)', [name]);
console.log(rows[0]);
res.json(rows[0]);
}
catch (err) {
console.error('database query failed', {
message: err.message,
code: err.code,
detail: err.detail,
hint: err.hint,
stack: err.stack,
});
res.status(500).send('Data was not pushed, request failed');
}
};
router.post('/', post_data);
module.exports = router;
+5 -2
View File
@@ -4,6 +4,8 @@ const pool = require('../../db');
const crypto = require('crypto'); const crypto = require('crypto');
const { promisify } = require('util'); const { promisify } = require('util');
const { sanitizeLoginInput } = require('../../middleware/sanitize_person_input'); const { sanitizeLoginInput } = require('../../middleware/sanitize_person_input');
const { strictInput } = require('../../middleware/strict_input');
const { signToken } = require('../../middleware/authenticate_token');
const scrypt = promisify(crypto.scrypt); const scrypt = promisify(crypto.scrypt);
@@ -32,7 +34,8 @@ async function login(req, res) {
return; return;
} }
res.status(200).json({ id: rows[0].id }); const { token, expiresAt } = signToken(rows[0].id);
res.status(200).json({ id: rows[0].id, token, expires_at: expiresAt });
} }
catch (err) { catch (err) {
console.error('login failed', { message: err.message, code: err.code }); console.error('login failed', { message: err.message, code: err.code });
@@ -40,5 +43,5 @@ async function login(req, res) {
} }
}; };
router.post('/', sanitizeLoginInput, login); router.post('/', strictInput({ body: ['email', 'password'] }), sanitizeLoginInput, login);
module.exports = router; module.exports = router;
+2 -1
View File
@@ -5,6 +5,7 @@ const pool = require('../../db');
var crypto = require('crypto'); var crypto = require('crypto');
const { promisify } = require('util'); const { promisify } = require('util');
const { sanitizePersonInput } = require('../../middleware/sanitize_person_input'); const { sanitizePersonInput } = require('../../middleware/sanitize_person_input');
const { strictInput } = require('../../middleware/strict_input');
const scrypt = promisify(crypto.scrypt); const scrypt = promisify(crypto.scrypt);
@@ -84,5 +85,5 @@ async function create_people(req, res) {
}; };
router.post('/', sanitizePersonInput, create_people); router.post('/', strictInput({ body: ['name', 'username', 'email', 'password'] }), sanitizePersonInput, create_people);
module.exports = router; module.exports = router;
+51
View File
@@ -0,0 +1,51 @@
const express = require("express");
const router = express.Router();
const pool = require("../../db");
const { strictInput } = require("../../middleware/strict_input");
const { authenticateToken } = require("../../middleware/authenticate_token");
const {
handlePostUpload,
imageLinkForFile,
removeUploadedFile,
} = require("../../middleware/post_upload");
async function create_post(req, res) {
try {
const { title, text } = req.body;
const author_id = req.user.id;
if (!title || !text) {
await removeUploadedFile(req.file);
return res.status(400).send("Title or text was not provided");
} else {
const imageLink = imageLinkForFile(req.file);
try {
const { rows } = await pool.query(
"INSERT INTO posts (title, text, author_id, image_link) VALUES ($1, $2, $3, $4) RETURNING id, title, text, author_id, image_link",
[title, text, author_id, imageLink],
);
res.status(201).json(rows[0]);
} catch (err) {
await removeUploadedFile(req.file);
throw err;
}
}
} catch (err) {
console.error("database query failed", {
message: err.message,
code: err.code,
detail: err.detail,
hint: err.hint,
stack: err.stack,
});
res.status(500).send("Data was not pushed, request failed");
}
}
router.post(
"/",
authenticateToken,
handlePostUpload,
strictInput({ body: ["title", "text"], cleanupUploadedFile: true }),
create_post,
);
module.exports = router;
+40
View File
@@ -0,0 +1,40 @@
const express = require("express");
const router = express.Router();
const pool = require("../../db");
const { strictInput } = require("../../middleware/strict_input");
const { authenticateToken } = require("../../middleware/authenticate_token");
const { removeImageLink } = require("../../middleware/post_upload");
async function delete_post(req, res) {
try {
const id = req.query.id;
const result = await pool.query(
"DELETE FROM posts WHERE id = $1 AND author_id = $2 RETURNING id, image_link",
[id, req.user.id],
);
if (result.rowCount === 0) {
return res.status(404).send("Post not found or does not belong to you");
}
await removeImageLink(result.rows[0].image_link);
res.status(200).json({
message: "Post Deleted",
});
} catch (err) {
console.error("database query failed", {
message: err.message,
code: err.code,
detail: err.detail,
hint: err.hint,
stack: err.stack,
});
res.status(500).send("Data has not been deleted, request failed");
}
}
router.delete(
"/",
authenticateToken,
strictInput({ query: ["id"] }),
delete_post,
);
module.exports = router;
+100
View File
@@ -0,0 +1,100 @@
const express = require("express");
const router = express.Router();
const pool = require("../../db");
const { strictInput } = require("../../middleware/strict_input");
const { authenticateToken } = require("../../middleware/authenticate_token");
const {
handlePostUpload,
imageLinkForFile,
removeImageLink,
removeUploadedFile,
} = require("../../middleware/post_upload");
async function editPost(req, res) {
const id = req.query.id;
const { title, text, remove_image: removeImageValue } = req.body;
const removeImage = removeImageValue === "true";
if (removeImageValue !== undefined && !["true", "false"].includes(removeImageValue)) {
await removeUploadedFile(req.file);
return res.status(400).send("remove_image must be true or false");
}
if (req.file && removeImage) {
await removeUploadedFile(req.file);
return res.status(400).send("Choose either an image or remove_image=true");
}
if (title === "" || text === "") {
await removeUploadedFile(req.file);
return res.status(400).send("title and text cannot be empty");
}
if (title === undefined && text === undefined && !req.file && !removeImage) {
return res.status(400).send("At least one post field must be changed");
}
try {
const existing = await pool.query(
"SELECT id, image_link FROM posts WHERE id = $1 AND author_id = $2",
[id, req.user.id],
);
if (existing.rowCount === 0) {
await removeUploadedFile(req.file);
return res.status(404).send("Post not found or does not belong to you");
}
const oldImageLink = existing.rows[0].image_link;
const fields = [];
const values = [];
const addField = (field, value) => {
fields.push(`${field} = $${values.length + 1}`);
values.push(value);
};
if (title !== undefined) addField("title", title);
if (text !== undefined) addField("text", text);
if (req.file) addField("image_link", imageLinkForFile(req.file));
if (removeImage) addField("image_link", null);
values.push(id, req.user.id);
let updated;
try {
updated = await pool.query(
`UPDATE posts SET ${fields.join(", ")} WHERE id = $${values.length - 1} AND author_id = $${values.length} RETURNING id, title, text, author_id, image_link`,
values,
);
} catch (err) {
await removeUploadedFile(req.file);
throw err;
}
if (req.file || removeImage) {
await removeImageLink(oldImageLink).catch((err) => {
console.error("old post image could not be removed", { message: err.message });
});
}
return res.status(200).json(updated.rows[0]);
} catch (err) {
await removeUploadedFile(req.file);
console.error("database query failed", {
message: err.message,
code: err.code,
detail: err.detail,
hint: err.hint,
stack: err.stack,
});
return res.status(500).send("Post was not updated, request failed");
}
}
router.put(
"/",
authenticateToken,
handlePostUpload,
strictInput({
query: ["id"],
body: ["title", "text", "remove_image"],
cleanupUploadedFile: true,
}),
editPost,
);
module.exports = router;
+56
View File
@@ -0,0 +1,56 @@
const express = require("express");
const router = express.Router();
const pool = require("../../db");
const { strictInput } = require("../../middleware/strict_input");
const { authenticateToken } = require("../../middleware/authenticate_token");
const { uploadDirectory } = require("../../middleware/post_upload");
const path = require("path");
async function getPosts(req, res, ownOnly) {
try {
const query = ownOnly
? "SELECT id, title, text, author_id, image_link FROM posts WHERE author_id = $1"
: "SELECT id, title, text, author_id, image_link FROM posts";
const values = ownOnly ? [req.user.id] : [];
const { rows } = await pool.query(query, values);
res.status(200).json(rows);
} catch (err) {
console.error("database query failed", {
message: err.message,
code: err.code,
detail: err.detail,
hint: err.hint,
stack: err.stack,
});
res.status(500).send("Posts could not be retrieved, request failed");
}
}
router.get(
"/image/:filename",
authenticateToken,
strictInput(),
(req, res) => {
const filename = path.basename(req.params.filename);
if (filename !== req.params.filename) return res.status(404).end();
res.sendFile(filename, { root: uploadDirectory }, (err) => {
if (err && !res.headersSent) res.status(err.statusCode === 404 ? 404 : 500).end();
});
},
);
router.get(
"/me",
authenticateToken,
strictInput(),
(req, res) => getPosts(req, res, true),
);
router.get(
"/",
authenticateToken,
strictInput(),
(req, res) => getPosts(req, res, false),
);
module.exports = router;
+26 -22
View File
@@ -1,28 +1,32 @@
const express = require('express'); const express = require("express");
const router = express.Router(); const router = express.Router();
var driver = require('../../graph_db'); var driver = require("../../graph_db");
const { strictInput } = require('../../middleware/strict_input');
async function create_friendship(req, res) { async function create_friendship(req, res) {
try { try {
const me = req.query.me; const me = req.query.me;
const them = req.query.them; const them = req.query.them;
const { records } = await driver.executeQuery('MATCH (m:Person {name: $me}) MATCH (t:Person {name: $them}) CREATE (m)-[:FRIENDS_WITH]-> (t) CREATE (t)-[:FRIENDS_WITH]-> (m)', { me: me, them: them }, { database: process.env.GRAPH_DB_NAME}); if (String(req.user.id) !== String(me)) {
res.status(200).send('Relationship has been created'); return res.status(403).send("You can only create relationships as yourself");
} }
catch (err) { const { records } = await driver.executeQuery(
console.error('database query failed', { "MATCH (m:Person {db_id: $me}) MATCH (t:Person {db_id: $them}) CREATE (m)-[:FRIENDS_WITH]-> (t) CREATE (t)-[:FRIENDS_WITH]-> (m)",
message: err.message, { me: me, them: them },
code: err.code, { database: process.env.GRAPH_DB_NAME },
detail: err.detail, );
hint: err.hint, res.status(200).send("Relationship has been created");
stack: err.stack, } catch (err) {
}); console.error("database query failed", {
res.status(500).send('Relationship was not created, request failed'); message: err.message,
} code: err.code,
}; detail: err.detail,
hint: err.hint,
stack: err.stack,
});
res.status(500).send("Relationship was not created, request failed");
}
}
router.post("/", strictInput({ query: ['me', 'them'] }), create_friendship);
router.post('/', create_friendship);
module.exports = router; module.exports = router;
+85
View File
@@ -0,0 +1,85 @@
const crypto = require('crypto');
const DEFAULT_TOKEN_LIFETIME_SECONDS = 60 * 60;
function base64url(value) {
return Buffer.from(value).toString('base64url');
}
function getJwtSecret() {
const secret = process.env.JWT_SECRET;
if (!secret || secret.length < 32) {
throw new Error('JWT_SECRET must be configured and contain at least 32 characters');
}
return secret;
}
function signToken(userId) {
const now = Math.floor(Date.now() / 1000);
const configuredLifetime = Number.parseInt(process.env.JWT_EXPIRES_IN, 10);
const lifetime = Number.isInteger(configuredLifetime) && configuredLifetime > 0
? configuredLifetime
: DEFAULT_TOKEN_LIFETIME_SECONDS;
const payload = {
user_id: userId,
iat: now,
exp: now + lifetime,
};
const encodedHeader = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const encodedPayload = base64url(JSON.stringify(payload));
const content = `${encodedHeader}.${encodedPayload}`;
const signature = crypto
.createHmac('sha256', getJwtSecret())
.update(content)
.digest('base64url');
return { token: `${content}.${signature}`, expiresAt: payload.exp };
}
function authenticateToken(req, res, next) {
const authorization = req.get('authorization') || '';
const match = authorization.match(/^Bearer\s+([^\s]+)$/i);
if (!match) {
return res.status(401).send('A valid Bearer token is required');
}
try {
const [encodedHeader, encodedPayload, providedSignature] = match[1].split('.');
if (!encodedHeader || !encodedPayload || !providedSignature) {
throw new Error('Malformed token');
}
const header = JSON.parse(Buffer.from(encodedHeader, 'base64url').toString('utf8'));
const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));
if (header.alg !== 'HS256' || header.typ !== 'JWT') {
throw new Error('Unsupported token');
}
const expectedSignature = crypto
.createHmac('sha256', getJwtSecret())
.update(`${encodedHeader}.${encodedPayload}`)
.digest('base64url');
const provided = Buffer.from(providedSignature);
const expected = Buffer.from(expectedSignature);
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
throw new Error('Invalid signature');
}
if (!Number.isInteger(payload.user_id) || payload.user_id <= 0 ||
!Number.isInteger(payload.exp) || payload.exp <= Math.floor(Date.now() / 1000)) {
throw new Error('Invalid or expired token');
}
req.user = { id: payload.user_id };
next();
} catch (err) {
if (err.message.startsWith('JWT_SECRET')) {
console.error('[auth] JWT configuration error:', err.message);
return res.status(500).send('Authentication is not configured');
}
return res.status(401).send('Invalid or expired token');
}
}
module.exports = { authenticateToken, signToken };
+69
View File
@@ -0,0 +1,69 @@
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const multer = require("multer");
const uploadDirectory = path.resolve(
process.env.UPLOAD_DIR || path.join(process.cwd(), "uploads"),
);
const maxFileSize = Number.parseInt(process.env.MAX_UPLOAD_SIZE_BYTES, 10) || 5 * 1024 * 1024;
const extensionByMimeType = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
};
fs.mkdirSync(uploadDirectory, { recursive: true });
const storage = multer.diskStorage({
destination: uploadDirectory,
filename: (req, file, callback) => {
callback(null, `${crypto.randomUUID()}${extensionByMimeType[file.mimetype]}`);
},
});
const uploadImage = multer({
storage,
limits: { fileSize: maxFileSize, files: 1 },
fileFilter: (req, file, callback) => {
if (!extensionByMimeType[file.mimetype]) {
return callback(new Error("Only JPEG, PNG, GIF, and WebP images are allowed"));
}
callback(null, true);
},
}).single("image");
function handlePostUpload(req, res, next) {
uploadImage(req, res, (err) => {
if (!err) return next();
if (err.code === "LIMIT_FILE_SIZE") {
return res.status(413).send("Image exceeds the maximum allowed size");
}
return res.status(400).send(err.message);
});
}
function imageLinkForFile(file) {
return file ? `/posts/image/${file.filename}` : null;
}
async function removeImageLink(imageLink) {
if (!imageLink || !imageLink.startsWith("/posts/image/")) return;
const filename = path.basename(imageLink);
await fs.promises.unlink(path.join(uploadDirectory, filename)).catch((err) => {
if (err.code !== "ENOENT") throw err;
});
}
async function removeUploadedFile(file) {
if (file) await removeImageLink(imageLinkForFile(file));
}
module.exports = {
handlePostUpload,
imageLinkForFile,
removeImageLink,
removeUploadedFile,
uploadDirectory,
};
+27
View File
@@ -0,0 +1,27 @@
const fs = require('fs');
function strictInput({ body = [], query = [], cleanupUploadedFile = false } = {}) {
const allowedBody = new Set(body);
const allowedQuery = new Set(query);
return function validateInputShape(req, res, next) {
const bodyKeys = Object.keys(req.body || {});
const queryKeys = Object.keys(req.query || {});
const unexpectedBody = bodyKeys.filter((key) => !allowedBody.has(key));
const unexpectedQuery = queryKeys.filter((key) => !allowedQuery.has(key));
if (unexpectedBody.length > 0 || unexpectedQuery.length > 0) {
if (cleanupUploadedFile && req.file?.path) {
fs.promises.unlink(req.file.path).catch(() => {});
}
return res.status(400).json({
error: 'Unexpected request field(s)',
fields: [...unexpectedBody, ...unexpectedQuery],
});
}
next();
};
}
module.exports = { strictInput };
+135
View File
@@ -13,6 +13,7 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"fakeout": "^1.0.65", "fakeout": "^1.0.65",
"multer": "^2.2.0",
"neo4j-driver": "^6.2.0", "neo4j-driver": "^6.2.0",
"pg": "^8.22.0", "pg": "^8.22.0",
"router": "^2.2.0" "router": "^2.2.0"
@@ -48,6 +49,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -178,6 +185,23 @@
"ieee754": "^1.2.1" "ieee754": "^1.2.1"
} }
}, },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -241,6 +265,21 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -841,6 +880,68 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -1186,6 +1287,20 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -1417,6 +1532,14 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -1508,6 +1631,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/undefsafe": { "node_modules/undefsafe": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -1524,6 +1653,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+1
View File
@@ -16,6 +16,7 @@
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"fakeout": "^1.0.65", "fakeout": "^1.0.65",
"multer": "^2.2.0",
"neo4j-driver": "^6.2.0", "neo4j-driver": "^6.2.0",
"pg": "^8.22.0", "pg": "^8.22.0",
"router": "^2.2.0" "router": "^2.2.0"