Security
CivicPulse is designed with security as a first-class concern. This page documents the specific controls implemented for each layer of the application.
OWASP Top 10 Mitigationsβ
| # | Threat | Control |
|---|---|---|
| A01 | Broken Access Control | Role middleware, ownership checks on every mutation |
| A02 | Cryptographic Failures | JWT HS256 + strong secrets, httpOnly cookies, TLS in production |
| A03 | Injection | express-validator, express-mongo-sanitize, parameterised DynamoDB queries |
| A04 | Insecure Design | Short-lived tokens, principle of least privilege on IAM |
| A05 | Security Misconfiguration | helmet(), explicit CORS allowlist, no default creds |
| A06 | Vulnerable Components | npm audit in CI, Dependabot alerts enabled |
| A07 | Identification & Auth Failures | Rate limit on auth routes, refresh token rotation |
| A08 | Software & Data Integrity | Lockfiles committed, integrity verified in CI |
| A09 | Security Logging & Monitoring | Structured logs, immutable assignment audit trail |
| A10 | Server-Side Request Forgery | S3 presigned URLs scoped to a single bucket/prefix |
Helmet.jsβ
Helmet is applied as the first middleware, setting the following HTTP response headers on every request:
| Header | Value / Behaviour |
|---|---|
Content-Security-Policy | Restricts sources for scripts, styles, images, and frames |
Cross-Origin-Embedder-Policy | require-corp |
Cross-Origin-Opener-Policy | same-origin |
Cross-Origin-Resource-Policy | same-origin |
Referrer-Policy | strict-origin-when-cross-origin |
Strict-Transport-Security | max-age=31536000; includeSubDomains (HTTPS only) |
X-Content-Type-Options | nosniff |
X-DNS-Prefetch-Control | off |
X-Download-Options | noopen |
X-Frame-Options | DENY |
X-Permitted-Cross-Domain-Policies | none |
X-XSS-Protection | 0 (browser XSS auditor is deprecated and harmful) |
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // needed for Socket.IO client
imgSrc: ["'self'", "data:", "https://civicpulse-media.s3.ap-south-1.amazonaws.com"],
connectSrc: ["'self'", "wss://api.civicpulse.in"],
frameSrc: ["'none'"],
},
},
}));
CORSβ
CORS is configured with an explicit allowlist. Requests from unlisted origins receive a CORS error and are blocked by the browser.
import cors from 'cors';
const allowedOrigins = process.env.CORS_ORIGIN.split(',').map(o => o.trim());
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (e.g. curl, Postman) in development only
if (!origin && process.env.NODE_ENV !== 'production') return callback(null, true);
if (allowedOrigins.includes(origin)) return callback(null, true);
callback(new Error(`Origin ${origin} not allowed by CORS`));
},
credentials: true, // required for httpOnly refresh cookie
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
Rate Limitingβ
Two separate rate limiters are applied using express-rate-limit:
import rateLimit from 'express-rate-limit';
// General limit β applied to all /api/* routes
const generalLimiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
max: parseInt(process.env.RATE_LIMIT_MAX) || 100,
standardHeaders: true,
legacyHeaders: false,
message: { success: false, error: { code: 'RATE_LIMITED', message: 'Too many requests' } },
});
// Auth limit β stricter, applied to /api/auth/* only
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api', generalLimiter);
app.use('/api/auth', authLimiter);
Rate limit state is stored in memory per worker. In cluster mode, limits are per-worker, not global β for accurate global rate limiting in production, replace the default memory store with a Redis store using rate-limit-redis.
Input Validationβ
Every route that accepts user input has an express-validator chain. Validation runs before the route handler; if any check fails, a 400 VALIDATION_ERROR response is returned immediately.
import { body, param, query, validationResult } from 'express-validator';
const createIssueRules = [
body('title').trim().notEmpty().isLength({ min: 10, max: 200 }),
body('description').trim().notEmpty().isLength({ min: 20, max: 2000 }),
body('city').trim().isIn(['mumbai', 'bangalore', 'delhi', /* ... */]),
body('category').trim().isIn(['road', 'infrastructure', 'hygiene', /* ... */]),
body('priority').trim().isIn(['low', 'medium', 'high']),
body('location.lat').optional().isFloat({ min: -90, max: 90 }),
body('location.lng').optional().isFloat({ min: -180, max: 180 }),
];
router.post('/issues', authenticateToken, createIssueRules, (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'Validation failed', details: errors.array() },
});
}
// ... handler
});
XSS Sanitizationβ
Two layers of XSS protection are applied:
1. express-mongo-sanitize β strips $ prefixes and . from keys in req.body, req.params, and req.query to prevent NoSQL injection patterns (defensive depth, even though CivicPulse uses DynamoDB):
import mongoSanitize from 'express-mongo-sanitize';
app.use(mongoSanitize());
2. Custom string sanitizer β applied to all string fields in request bodies before they are written to DynamoDB. Strips HTML tags and encodes dangerous characters:
function sanitizeString(str) {
return str
.replace(/<[^>]*>/g, '') // strip HTML tags
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
JWT Securityβ
| Property | Value |
|---|---|
| Algorithm | HS256 |
| Access token expiry | 15 minutes |
| Refresh token expiry | 7 days |
| Refresh token storage | httpOnly, Secure, SameSite=Strict cookie |
| Refresh token rotation | Yes β every refresh issues a new token and revokes the old one |
| Revocation store | DynamoDB β revoked token JTI list with TTL |
On each call to POST /api/auth/refresh, the server:
- Reads the refresh token from the httpOnly cookie
- Verifies the signature and expiry
- Checks the token's
jti(JWT ID) against the DynamoDB revocation list - Issues a new access token and a new refresh token
- Revokes the old refresh token by writing its
jtito the revocation list with the original expiry as the DynamoDB TTL
Role-Based Access Controlβ
Three roles are defined:
| Role | Assigned by | Capabilities |
|---|---|---|
citizen | Default on registration | Report issues, upvote, comment, join events |
official | Admin assignment | All citizen actions + assign issues to officials, update status |
admin | Manual DB update | All official actions + bulk operations, user management |
Roles are encoded in the JWT and enforced by the requireRole middleware:
function requireRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({
success: false,
error: { code: 'FORBIDDEN', message: `Role '${req.user.role}' cannot perform this action` },
});
}
next();
};
}
HTTP Parameter Pollution Protectionβ
hpp prevents attackers from sending duplicate query parameters that could bypass validation:
import hpp from 'hpp';
app.use(hpp());
Example attack this prevents:
GET /api/issues?status=open&status=resolved&status[$ne]=open
Without HPP, req.query.status would be an array. HPP always selects the last value, ensuring validators receive a string.
Audit Trail β Assignment Logβ
Every issue assignment (including reassignments) writes an immutable ASSIGNMENT_LOG record to DynamoDB. This record is never updated or deleted:
{
"PK": "ISSUE#iss_01HN...",
"SK": "ASSIGNMENT#2025-01-16T10:00:00.000Z",
"entityType": "ASSIGNMENT_LOG",
"issueId": "iss_01HN...",
"assignedTo": "off_01HN...",
"assignedBy": "admin_01HN...",
"department": "BBMP β Roads & Infrastructure",
"note": "Escalated due to 40+ upvotes",
"assignedAt": "2025-01-16T10:00:00.000Z"
}
The audit trail supports accountability for government officials and can be subpoenaed or exported for RTI (Right to Information) requests under Indian law.