From 3acd41e1f7fcefcdb8995097046cd84295f15434 Mon Sep 17 00:00:00 2001 From: Rafi Athallah <65345768+rafiathallah3@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:07:26 +0700 Subject: [PATCH] feat(security): pengerasan keamanan API internify-be (CORS, Rate Limiting, Helmet, Multer, Cookie) --- .env.example | 1 + package-lock.json | 43 ++++++++++++++++++ package.json | 2 + src/helpers/db/db_connection.js | 4 +- src/index.js | 44 +++++++++++++++++-- src/middleware/multer.js | 16 ++++++- src/middleware/rateLimiter.js | 32 ++++++++++++++ .../auth/controllers/auth.controller.js | 10 +++-- src/routes/auth.routes.js | 7 +-- 9 files changed, 148 insertions(+), 11 deletions(-) create mode 100644 src/middleware/rateLimiter.js diff --git a/.env.example b/.env.example index 47ab770..9ab253c 100755 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ PORT=********** NODE_ENV=production +ALLOWED_ORIGINS="http://localhost:5173,http://localhost:3000" SEED_ADMIN_EMAIL=admin@internify.com SEED_ADMIN_PASSWORD=**** diff --git a/package-lock.json b/package-lock.json index 6ffc4ef..da82825 100755 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,9 @@ "dotenv": "^16.5.0", "exceljs": "^4.4.0", "express": "^4.21.2", + "express-rate-limit": "^8.6.1", "express-recaptcha": "^5.1.0", + "helmet": "^8.3.0", "joi": "^18.0.1", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.2", @@ -1631,6 +1633,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -1672,6 +1675,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-recaptcha": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express-recaptcha/-/express-recaptcha-5.1.0.tgz", @@ -2129,6 +2151,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -2219,6 +2253,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index 752aeac..530658f 100755 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "dotenv": "^16.5.0", "exceljs": "^4.4.0", "express": "^4.21.2", + "express-rate-limit": "^8.6.1", "express-recaptcha": "^5.1.0", + "helmet": "^8.3.0", "joi": "^18.0.1", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.2", diff --git a/src/helpers/db/db_connection.js b/src/helpers/db/db_connection.js index 19a70a4..66979d3 100755 --- a/src/helpers/db/db_connection.js +++ b/src/helpers/db/db_connection.js @@ -1,7 +1,9 @@ const { PrismaClient } = require('../../generated/prisma'); const prisma = new PrismaClient({ - log: ['query', 'info', 'warn', 'error'], + log: process.env.NODE_ENV === 'production' + ? ['error', 'warn'] + : ['query', 'info', 'warn', 'error'], }); module.exports = prisma \ No newline at end of file diff --git a/src/index.js b/src/index.js index d0006e8..7571fdd 100755 --- a/src/index.js +++ b/src/index.js @@ -3,6 +3,7 @@ const express = require('express'); const http = require('http'); const { initSocket } = require('./helpers/socket/socket_connection'); const cors = require('cors'); +const helmet = require('helmet'); const path = require('path'); const adminRoutes = require('./routes/admin.routes'); const authRoutes = require('./routes/auth.routes'); @@ -23,8 +24,17 @@ const setupSwaggerDocs = require('./docs/swagger'); const PORT = process.env.PORT; const app = express(); +const allowedOrigins = (process.env.ALLOWED_ORIGINS || 'http://localhost:5173,http://localhost:3000').split(','); + const corsOptions = { - origin: true, + origin: (origin, callback) => { + // allow requests with no origin (like mobile apps or curl requests) or origins in allowedOrigins + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS policy')); + } + }, methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"], credentials: true, allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With", "Accept"], @@ -36,12 +46,22 @@ const uploadDir = process.env.NODE_ENV === 'production' ? '/tmp/uploads' : path.join(__dirname, 'uploads'); +app.use(helmet({ + crossOriginResourcePolicy: { policy: "cross-origin" }, + contentSecurityPolicy: false, +})); + app.options('*', cors(corsOptions)); -app.use(express.json()); +app.use(express.json({ limit: '100kb' })); app.use(cors(corsOptions)); -app.use("/uploads", express.static(uploadDir), (req, res, next) => { +app.use("/uploads", express.static(uploadDir, { + setHeaders: (res) => { + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Content-Disposition", "inline"); + }, +}), (req, res, next) => { if (!res.headersSent) { res.status(404).json({ status: false, @@ -77,6 +97,24 @@ app.use((req, res) => { }); }); +app.use((err, req, res, next) => { + const statusCode = err.status || err.statusCode || 500; + const message = process.env.NODE_ENV === 'production' && statusCode === 500 + ? 'Internal Server Error' + : err.message || 'Unexpected server error'; + + if (statusCode === 500) { + console.error('[Unhandled Error]:', err); + } + + res.status(statusCode).json({ + status: false, + data: null, + message, + code: statusCode, + }); +}); + const server = http.createServer(app); initSocket(server); diff --git a/src/middleware/multer.js b/src/middleware/multer.js index a48f29f..5bab430 100755 --- a/src/middleware/multer.js +++ b/src/middleware/multer.js @@ -15,7 +15,8 @@ const storage = multer.diskStorage({ destination: (_req, _file, cb) => cb(null, uploadDir), filename: (_req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); - cb(null, uniqueSuffix + path.extname(file.originalname)); + const safeExt = path.extname(file.originalname).replace(/[^a-zA-Z0-9.]/g, '').toLowerCase(); + cb(null, `${uniqueSuffix}${safeExt}`); }, }); @@ -39,6 +40,10 @@ const fileFilter = (req, file, cb) => { const upload = multer({ storage: storage, fileFilter: fileFilter, + limits: { + fileSize: 5 * 1024 * 1024, // 5MB Makss + files: 5, + }, }); // middleware untuk ngechek ukuran file @@ -88,6 +93,15 @@ const checkFileSizes = (req, res, next) => { // middleware untuk error multer const multerErrorHandler = (err, req, res, next) => { if (err instanceof multer.MulterError) { + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(413).json({ + status: false, + data: null, + message: 'Ukuran file terlalu besar. Maksimal 5MB', + code: 413, + }); + } + if (err.code === 'LIMIT_UNEXPECTED_FILE') { return res.status(400).json({ status: false, diff --git a/src/middleware/rateLimiter.js b/src/middleware/rateLimiter.js new file mode 100644 index 0000000..7a7170e --- /dev/null +++ b/src/middleware/rateLimiter.js @@ -0,0 +1,32 @@ +const rateLimit = require('express-rate-limit'); + +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 menit + max: 15, + standardHeaders: true, + legacyHeaders: false, + message: { + status: false, + data: null, + message: 'Kebanyakan login, mohon coba lagi setelah 15 menit', + code: 429, + }, +}); + +const globalLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 menit + max: 200, + standardHeaders: true, + legacyHeaders: false, + message: { + status: false, + data: null, + message: 'Terlalu banyak request, mohon coba lagi setelah 15 menit', + code: 429, + }, +}); + +module.exports = { + authLimiter, + globalLimiter, +}; diff --git a/src/modules/auth/controllers/auth.controller.js b/src/modules/auth/controllers/auth.controller.js index 24fbd49..2d19428 100755 --- a/src/modules/auth/controllers/auth.controller.js +++ b/src/modules/auth/controllers/auth.controller.js @@ -28,12 +28,11 @@ class AuthController { if (result.err) { return response(res, 'fail', result); } - console.log(result.data.token) res.cookie("token", result.data.token, { httpOnly: true, secure: true, - sameSite: 'None', - maxAge: 7 * 24 * 60 * 60, + sameSite: 'Lax', + maxAge: 7 * 24 * 60 * 60 * 1000, }); return response(res, 'success', result, 'Login berhasil', SUCCESS.OK); @@ -64,6 +63,11 @@ class AuthController { async logout(req, res) { try { + res.clearCookie("token", { + httpOnly: true, + secure: true, + sameSite: 'Lax', + }); return response(res, 'success', data(), 'Logout berhasil', SUCCESS.OK); } catch (err) { return response(res, 'fail', error(new InternalServerError(err.message)), 'Terjadi kesalahan yang tidak terduga', ERROR.INTERNAL_ERROR); diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js index 90ba201..4a91527 100755 --- a/src/routes/auth.routes.js +++ b/src/routes/auth.routes.js @@ -2,7 +2,8 @@ const express = require('express'); const { authController } = require('../modules/auth'); const { verifyJWT } = require('../middleware/verifyJWT'); const multer = require('../middleware/multer'); -const { multerErrorHandler } = require('../middleware/multer'); +const { multerErrorHandler, checkFileSizes } = require('../middleware/multer'); +const { authLimiter } = require('../middleware/rateLimiter'); const router = express.Router(); @@ -44,7 +45,7 @@ const router = express.Router(); * 500: * description: Internal server error */ -router.post("/login", authController.login); +router.post("/login", authLimiter, authController.login); /** * @swagger @@ -318,6 +319,6 @@ router.get("/me", verifyJWT, authController.me); * 500: * description: Internal server error */ -router.patch('/update-profile', verifyJWT, multer.single('profile_picture'), multerErrorHandler, authController.updateProfile); +router.patch('/update-profile', verifyJWT, multer.single('profile_picture'), checkFileSizes, multerErrorHandler, authController.updateProfile); module.exports = router;