Added security policies to all endpoints and implemented file upload feature on posts
This commit is contained in:
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user