Skip to content

Commit 3dfff6a

Browse files
parsa-farajiclaude
andcommitted
fix: harden backend security — CORS, error handling, Firestore rules
Backend/server.js: - Replace permissive cors() with origin whitelist (CORS_ALLOWED_ORIGINS env var, defaults to localhost dev ports) - Add JSON body size limit (1 MB) to prevent large-payload attacks - Add security headers (X-Content-Type-Options, X-Frame-Options, X-XSS-Protection) and strip X-Powered-By - Add /health endpoint for monitoring - Add 404 catch-all and global error handler that hides stack traces in production Backend/firebase.js: - Fail fast with a clear error message when required Firebase Admin environment variables are missing (instead of silently initializing with a dummy project) Backend/middleware/verifyToken.js: - Add empty-token guard - Log token verification failure codes for debugging without leaking sensitive information firestore.rules: - Replace catch-all "any authenticated user can read/write anything" rule with per-collection rules enforcing ownership (users can only write their own profile, spots/ratings/groups respect createdBy/ ownerId fields) - Default deny for any uncovered paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f2af409 commit 3dfff6a

4 files changed

Lines changed: 155 additions & 23 deletions

File tree

Backend/firebase.js

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,33 @@
11
const admin = require('firebase-admin');
22
require('dotenv').config();
33

4-
if (process.env.FIREBASE_PRIVATE_KEY) {
5-
admin.initializeApp({
6-
credential: admin.credential.cert({
7-
projectId: process.env.FIREBASE_PROJECT_ID,
8-
privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
9-
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
10-
}),
11-
});
12-
} else {
13-
console.warn('FIREBASE_PRIVATE_KEY is missing. Firebase Admin SDK not initialized.');
14-
// Initialize with dummy config just to prevent crashes if it's used elsewhere
15-
admin.initializeApp({ projectId: 'dummy-project' });
4+
// ---------------------------------------------------------------------------
5+
// Firebase Admin SDK initialization
6+
// ---------------------------------------------------------------------------
7+
const requiredVars = [
8+
'FIREBASE_PROJECT_ID',
9+
'FIREBASE_PRIVATE_KEY',
10+
'FIREBASE_CLIENT_EMAIL',
11+
];
12+
13+
const missing = requiredVars.filter((v) => !process.env[v]);
14+
15+
if (missing.length > 0) {
16+
console.error(
17+
`[firebase] Missing required environment variables: ${missing.join(', ')}\n` +
18+
'See Backend/.env.example for the full list of required variables.'
19+
);
20+
process.exit(1);
1621
}
1722

23+
admin.initializeApp({
24+
credential: admin.credential.cert({
25+
projectId: process.env.FIREBASE_PROJECT_ID,
26+
privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
27+
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
28+
}),
29+
});
30+
1831
const db = admin.firestore();
32+
1933
module.exports = { admin, db };

Backend/middleware/verifyToken.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
11
const { admin } = require('../firebase');
22

3+
/**
4+
* Express middleware that verifies a Firebase ID token from the
5+
* Authorization header. On success, attaches the decoded token to
6+
* `req.user` so downstream handlers can access `req.user.uid`.
7+
*/
38
const verifyToken = async (req, res, next) => {
49
const authHeader = req.headers.authorization;
510

611
if (!authHeader || !authHeader.startsWith('Bearer ')) {
7-
return res.status(401).json({ error: 'No token provided' });
12+
return res.status(401).json({ error: 'Missing or malformed Authorization header' });
813
}
914

1015
const token = authHeader.split('Bearer ')[1];
1116

17+
if (!token || token.length === 0) {
18+
return res.status(401).json({ error: 'Token is empty' });
19+
}
20+
1221
try {
1322
const decodedToken = await admin.auth().verifyIdToken(token);
14-
req.user = decodedToken; // now any route can access req.user.uid
23+
req.user = decodedToken;
1524
next();
1625
} catch (err) {
26+
console.error('[verifyToken] Token verification failed:', err.code || err.message);
1727
return res.status(401).json({ error: 'Invalid or expired token' });
1828
}
1929
};

Backend/server.js

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,92 @@
11
const express = require('express');
22
const cors = require('cors');
33
const verifyToken = require('./middleware/verifyToken');
4+
require('dotenv').config();
45

56
const app = express();
6-
app.use(cors());
7-
app.use(express.json());
87

9-
// A protected route
8+
// ---------------------------------------------------------------------------
9+
// Security: Restrict CORS to known origins in production
10+
// ---------------------------------------------------------------------------
11+
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
12+
? process.env.CORS_ALLOWED_ORIGINS.split(',').map((o) => o.trim())
13+
: ['http://localhost:5173', 'http://localhost:3000'];
14+
15+
app.use(
16+
cors({
17+
origin(origin, callback) {
18+
// Allow requests with no origin (e.g. server-to-server, curl)
19+
if (!origin || allowedOrigins.includes(origin)) {
20+
callback(null, true);
21+
} else {
22+
callback(new Error(`CORS: origin ${origin} not allowed`));
23+
}
24+
},
25+
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
26+
allowedHeaders: ['Content-Type', 'Authorization'],
27+
credentials: true,
28+
})
29+
);
30+
31+
// ---------------------------------------------------------------------------
32+
// Body parsing with size limit to prevent large-payload attacks
33+
// ---------------------------------------------------------------------------
34+
app.use(express.json({ limit: '1mb' }));
35+
36+
// ---------------------------------------------------------------------------
37+
// Security headers
38+
// ---------------------------------------------------------------------------
39+
app.use((_req, res, next) => {
40+
res.setHeader('X-Content-Type-Options', 'nosniff');
41+
res.setHeader('X-Frame-Options', 'DENY');
42+
res.setHeader('X-XSS-Protection', '1; mode=block');
43+
res.removeHeader('X-Powered-By');
44+
next();
45+
});
46+
47+
// ---------------------------------------------------------------------------
48+
// Health check (public)
49+
// ---------------------------------------------------------------------------
50+
app.get('/', (_req, res) => {
51+
res.json({ status: 'ok', timestamp: new Date().toISOString() });
52+
});
53+
54+
app.get('/health', (_req, res) => {
55+
res.json({ status: 'ok', timestamp: new Date().toISOString() });
56+
});
57+
58+
// ---------------------------------------------------------------------------
59+
// Protected routes
60+
// ---------------------------------------------------------------------------
1061
app.get('/profile', verifyToken, (req, res) => {
1162
res.json({ message: `Hello user ${req.user.uid}` });
1263
});
1364

14-
// Another public route just to test
15-
app.get('/', (req, res) => {
16-
res.send('Backend is running!');
65+
// ---------------------------------------------------------------------------
66+
// 404 handler
67+
// ---------------------------------------------------------------------------
68+
app.use((_req, res) => {
69+
res.status(404).json({ error: 'Route not found' });
1770
});
1871

72+
// ---------------------------------------------------------------------------
73+
// Global error handler
74+
// ---------------------------------------------------------------------------
75+
app.use((err, _req, res, _next) => {
76+
console.error('Unhandled error:', err.message);
77+
const status = err.status || 500;
78+
res.status(status).json({
79+
error:
80+
process.env.NODE_ENV === 'production'
81+
? 'Internal server error'
82+
: err.message,
83+
});
84+
});
85+
86+
// ---------------------------------------------------------------------------
87+
// Start server
88+
// ---------------------------------------------------------------------------
1989
const PORT = process.env.PORT || 5000;
20-
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
90+
app.listen(PORT, () => {
91+
console.log(`Server running on port ${PORT} (${process.env.NODE_ENV || 'development'})`);
92+
});

firestore.rules

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,45 @@ service cloud.firestore {
55
return request.auth != null;
66
}
77

8-
// Start locked down. Expand these rules as your data model is added.
8+
function isOwner(userId) {
9+
return isSignedIn() && request.auth.uid == userId;
10+
}
11+
12+
// Users: owners can read/write their own profile; others cannot
13+
match /users/{userId} {
14+
allow read: if isSignedIn();
15+
allow create: if isOwner(userId);
16+
allow update, delete: if isOwner(userId);
17+
}
18+
19+
// Study spots: any authenticated user can read; writes restricted
20+
match /spots/{spotId} {
21+
allow read: if isSignedIn();
22+
allow create: if isSignedIn();
23+
allow update, delete: if isSignedIn()
24+
&& resource.data.createdBy == request.auth.uid;
25+
}
26+
27+
// Ratings: authenticated users can read all; write only their own
28+
match /ratings/{ratingId} {
29+
allow read: if isSignedIn();
30+
allow create: if isSignedIn()
31+
&& request.resource.data.userId == request.auth.uid;
32+
allow update, delete: if isSignedIn()
33+
&& resource.data.userId == request.auth.uid;
34+
}
35+
36+
// Study groups: authenticated users can read; owner manages
37+
match /groups/{groupId} {
38+
allow read: if isSignedIn();
39+
allow create: if isSignedIn();
40+
allow update, delete: if isSignedIn()
41+
&& resource.data.ownerId == request.auth.uid;
42+
}
43+
44+
// Deny everything else by default
945
match /{document=**} {
10-
allow read, write: if isSignedIn();
46+
allow read, write: if false;
1147
}
1248
}
1349
}

0 commit comments

Comments
 (0)