Skip to content

Commit f901b42

Browse files
committed
feat(security): implement robust CSRF protection and multi-origin CORS
1 parent 6c0180e commit f901b42

6 files changed

Lines changed: 151 additions & 128 deletions

File tree

apps/dashboard-api/src/app.js

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,8 @@ app.set('trust proxy', 1);
1919
const {garbageCollect, storageGarbageCollect, getPublicIp} = require('@urbackend/common');
2020
const { capture } = require('@kiroo/sdk');
2121

22-
23-
// Initialize Queue Workers
2422
const { emailQueue, authEmailQueue } = require('@urbackend/common');
2523

26-
27-
app.use(express.json());
28-
app.use(express.urlencoded({ extended: true }));
29-
app.use(cookieParser());
30-
app.use(csurf({ cookie: true }));
31-
3224
const dashboardLimiter = rateLimit({
3325
windowMs: 15 * 60 * 1000,
3426
max: 1000,
@@ -37,23 +29,46 @@ const dashboardLimiter = rateLimit({
3729
});
3830

3931
const whitelist = (function() {
40-
const whitelist = [process.env.FRONTEND_URL];
32+
// Default allowed origins
33+
const allowed = [process.env.FRONTEND_URL];
34+
35+
// Support comma-separated list of origins in .env
36+
if (process.env.ALLOWED_ORIGINS) {
37+
const extraOrigins = process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim());
38+
allowed.push(...extraOrigins);
39+
}
40+
4141
if (process.env.NODE_ENV === 'development') {
42-
whitelist.push('http://localhost:5173');
42+
allowed.push('http://localhost:5173');
43+
allowed.push('http://localhost:3000');
4344
}
45+
46+
// Filter out duplicates and empty values
47+
const uniqueAllowed = [...new Set(allowed.filter(Boolean))];
48+
4449
return {
45-
get: () => {
46-
return whitelist.slice();
47-
}
50+
get: () => uniqueAllowed
4851
};
4952
})()
5053

51-
5254
app.use(cors({
5355
origin: whitelist.get(),
5456
credentials: true,
5557
}));
5658

59+
app.use(express.json());
60+
app.use(express.urlencoded({ extended: true }));
61+
62+
app.use(cookieParser());
63+
64+
app.use(csurf({
65+
cookie: {
66+
httpOnly: true,
67+
secure: process.env.NODE_ENV === 'production',
68+
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax'
69+
}
70+
}));
71+
5772

5873
if (process.env.NODE_ENV !== 'test') {
5974
garbageCollect();
@@ -69,14 +84,12 @@ app.use(capture({
6984

7085

7186

72-
// Route Imports
7387
const authRoute = require('./routes/auth');
7488
const projectRoute = require('./routes/projects');
7589
const releaseRoute = require('./routes/releases');
7690

77-
// ROUTES SETUP
7891
app.use('/api/auth', authRoute);
79-
app.use('/api/projects', dashboardLimiter, projectRoute); // Project Mngmt
92+
app.use('/api/projects', dashboardLimiter, projectRoute);
8093
app.use('/api/releases', releaseRoute);
8194

8295

@@ -87,13 +100,20 @@ app.get('/api/server-ip', async (req, res) => {
87100
res.json({ ip });
88101
});
89102

90-
// Test Route
91103
app.get('/', (req, res) => {
92104
res.status(200).json({ status: "success", message: "urBackend API is running 🚀" })
93105
});
94106

95107
// Global Error Handler
96108
app.use((err, req, res, next) => {
109+
// CSRF Error Handling
110+
if (err.code === 'EBADCSRFTOKEN') {
111+
return res.status(403).json({
112+
error: "Invalid CSRF token",
113+
message: "The form has expired or the CSRF token is invalid. Please refresh the page and try again."
114+
});
115+
}
116+
97117
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
98118
return res.status(400).json({
99119
error: "Invalid JSON format",
@@ -112,7 +132,6 @@ app.use((req, res) => {
112132
const id = res.get("X-Kiroo-Replay-ID");
113133
res.json({error: "Not Found", replayId: id})
114134
})
115-
// INITIALIZATION
116135
if (process.env.NODE_ENV !== 'test') {
117136

118137
const PORT = process.env.PORT || 1234;

apps/dashboard-api/src/controllers/auth.controller.js

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,14 @@ const sendTokenResponse = async (user, statusCode, res) => {
3232
{ expiresIn: REFRESH_TOKEN_EXPIRES_IN }
3333
);
3434

35-
// Save refresh token to DB (Rotation)
3635
user.refreshToken = refreshToken;
3736
await user.save();
3837

3938
const cookieOptions = {
4039
httpOnly: true,
4140
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
4241
secure: process.env.NODE_ENV === 'production',
43-
sameSite: 'Strict' // Use Strict for better CSRF protection
42+
sameSite: 'Strict'
4443
};
4544

4645
if (process.env.NODE_ENV === 'production') {
@@ -67,18 +66,15 @@ const sendTokenResponse = async (user, statusCode, res) => {
6766
async function createAndStoreOtp(userId) {
6867
const otp = crypto.randomInt(100000, 1000000).toString();
6968

70-
// Delete any existing OTP for this user
7169
await otpSchema.deleteOne({ userId });
7270

73-
// Hash OTP before storing
7471
const salt = await bcrypt.genSalt(10);
7572
const hashedOtp = await bcrypt.hash(otp, salt);
7673

7774
await new otpSchema({ userId, otp: hashedOtp }).save();
7875
return otp;
7976
}
8077

81-
// HELPER: Validate OTP with attempt tracking
8278
async function validateOtp(userId, passedOtp) {
8379
const otpDoc = await otpSchema.findOne({ userId });
8480
if (!otpDoc) throw { status: 400, message: "No OTP found. Please request a new one." };
@@ -101,7 +97,6 @@ async function validateOtp(userId, passedOtp) {
10197

10298
module.exports.register = async (req, res) => {
10399
try {
104-
// POST FOR - REGISTER
105100
const { email, password } = loginSchema.parse(req.body);
106101

107102
const existingUser = await Developer.findOne({ email });
@@ -148,7 +143,6 @@ module.exports.login = async (req, res) => {
148143

149144
module.exports.changePassword = async (req, res) => {
150145
try {
151-
// POST FOR - CHANGE PASSWORD
152146
const { currentPassword, newPassword } = changePasswordSchema.parse(req.body);
153147

154148
const dev = await Developer.findById(req.user._id);
@@ -173,7 +167,6 @@ module.exports.changePassword = async (req, res) => {
173167

174168
module.exports.deleteAccount = async (req, res) => {
175169
try {
176-
// POST FOR - DELETE ACCOUNT
177170
const { password } = deleteAccountSchema.parse(req.body);
178171

179172
const dev = await Developer.findById(req.user._id);
@@ -216,7 +209,6 @@ module.exports.sendOtp = async (req, res) => {
216209

217210
module.exports.verifyOtp = async (req, res) => {
218211
try {
219-
// POST FOR - VERIFY OTP
220212
const { email, otp } = verifyOtpSchema.parse(req.body);
221213

222214
const existingUser = await Developer.findOne({ email });
@@ -244,7 +236,6 @@ module.exports.forgotPassword = async (req, res) => {
244236
const { email } = onlyEmailSchema.parse(req.body);
245237

246238
const dev = await Developer.findOne({ email });
247-
// Return same message regardless of whether email exists (prevents user enumeration)
248239
if (!dev) return res.status(200).json({ message: "If this email is registered, an OTP has been sent." });
249240

250241
const otp = await createAndStoreOtp(dev._id);
@@ -262,15 +253,13 @@ module.exports.forgotPassword = async (req, res) => {
262253
// RESET PASSWORD
263254
module.exports.resetPassword = async (req, res) => {
264255
try {
265-
// POST FOR - RESET PASSWORD
266256
const { email, otp, newPassword } = resetPasswordSchema.parse(req.body);
267257

268258
const dev = await Developer.findOne({ email });
269259
if (!dev) return res.status(400).json({ error: "User not found" });
270260

271261
const otpDoc = await validateOtp(dev._id, otp);
272262

273-
// UPDATE PASSWORD
274263
await otpDoc.deleteOne();
275264
const salt = await bcrypt.genSalt(10);
276265
dev.password = await bcrypt.hash(newPassword, salt);

apps/dashboard-api/src/routes/auth.js

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,34 +26,29 @@ const dashboardLimiter = rateLimit({
2626
});
2727

2828

29-
// REGISTER ROUTE
3029
router.post('/register', authLimiter, register);
3130

32-
// LOGIN ROUTE
3331
router.post('/login', authLimiter, login);
3432

35-
// Apply more relaxed dashboard limiter to the rest
3633
router.use(dashboardLimiter);
3734

38-
// CHANGE PASSWORD (Protected)
3935
router.put('/change-password', authorization, changePassword);
4036

41-
// DELETE ACCOUNT (Protected - Danger Zone)
4237
router.delete('/delete-account', authorization, deleteAccount);
4338

44-
// OTP (email verification)
4539
router.post('/send-otp', sendOtp);
4640
router.post('/verify-otp', verifyOtp);
4741

48-
// PASSWORD RESET (FIX 5)
4942
router.post('/forgot-password', forgotPassword);
5043
router.post('/reset-password', resetPassword);
5144

52-
// REFRESH & LOGOUT
5345
router.post('/refresh-token', refreshToken);
5446
router.post('/logout', authorization, logout);
5547

56-
// GET ME
5748
router.get('/me', authorization, getMe);
5849

50+
router.get('/csrf-token', (req, res) => {
51+
res.json({ csrfToken: req.csrfToken() });
52+
});
53+
5954
module.exports = router;

apps/web-dashboard/dist/assets/index-O6PjDT8g.js renamed to apps/web-dashboard/dist/assets/index-C_cBA58F.js

Lines changed: 79 additions & 79 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/web-dashboard/dist/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap"
1313
rel="stylesheet"
1414
/>
15-
<script type="module" crossorigin src="/assets/index-O6PjDT8g.js"></script>
15+
<script type="module" crossorigin src="/assets/index-C_cBA58F.js"></script>
1616
<link rel="stylesheet" crossorigin href="/assets/index-DAhCOP-a.css">
1717
</head>
1818
<body>

apps/web-dashboard/src/utils/api.js

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,54 @@ import { API_URL } from '../config';
33

44
const api = axios.create({
55
baseURL: API_URL,
6-
withCredentials: true, // Crucial for sending/receiving cookies
6+
withCredentials: true,
77
});
88

9-
// Response interceptor to handle 401s and refresh token
9+
let csrfToken = null;
10+
11+
const fetchCsrfToken = async () => {
12+
try {
13+
const response = await axios.get(`${API_URL}/api/auth/csrf-token`, { withCredentials: true });
14+
csrfToken = response.data.csrfToken;
15+
return csrfToken;
16+
} catch (err) {
17+
console.error("Failed to fetch CSRF token:", err);
18+
return null;
19+
}
20+
};
21+
22+
api.interceptors.request.use(async (config) => {
23+
const method = config.method.toLowerCase();
24+
25+
if (['post', 'put', 'delete', 'patch'].includes(method)) {
26+
if (!csrfToken) {
27+
csrfToken = await fetchCsrfToken();
28+
}
29+
if (csrfToken) {
30+
config.headers['X-CSRF-Token'] = csrfToken;
31+
}
32+
}
33+
return config;
34+
}, (error) => Promise.reject(error));
35+
1036
api.interceptors.response.use(
1137
(response) => response,
1238
async (error) => {
1339
const originalRequest = error.config;
1440

15-
// If error is 401 and we haven't retried yet
1641
if (error.response?.status === 401 && !originalRequest._retry) {
1742

18-
// Avoid infinite loops if refresh-token itself fails with 401
1943
if (originalRequest.url.includes('/api/auth/refresh-token')) {
2044
return Promise.reject(error);
2145
}
2246

2347
originalRequest._retry = true;
2448

2549
try {
26-
// Attempt to refresh tokens
2750
await axios.post(`${API_URL}/api/auth/refresh-token`, {}, { withCredentials: true });
2851

29-
// If refresh succeeds, retry the original request
3052
return api(originalRequest);
3153
} catch (refreshError) {
32-
// If refresh fails, we can't do much - user needs to log in again
33-
// We'll let the component/AuthContext handle the final failure
3454
return Promise.reject(refreshError);
3555
}
3656
}

0 commit comments

Comments
 (0)