Skip to main content

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​

#ThreatControl
A01Broken Access ControlRole middleware, ownership checks on every mutation
A02Cryptographic FailuresJWT HS256 + strong secrets, httpOnly cookies, TLS in production
A03Injectionexpress-validator, express-mongo-sanitize, parameterised DynamoDB queries
A04Insecure DesignShort-lived tokens, principle of least privilege on IAM
A05Security Misconfigurationhelmet(), explicit CORS allowlist, no default creds
A06Vulnerable Componentsnpm audit in CI, Dependabot alerts enabled
A07Identification & Auth FailuresRate limit on auth routes, refresh token rotation
A08Software & Data IntegrityLockfiles committed, integrity verified in CI
A09Security Logging & MonitoringStructured logs, immutable assignment audit trail
A10Server-Side Request ForgeryS3 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:

HeaderValue / Behaviour
Content-Security-PolicyRestricts sources for scripts, styles, images, and frames
Cross-Origin-Embedder-Policyrequire-corp
Cross-Origin-Opener-Policysame-origin
Cross-Origin-Resource-Policysame-origin
Referrer-Policystrict-origin-when-cross-origin
Strict-Transport-Securitymax-age=31536000; includeSubDomains (HTTPS only)
X-Content-Type-Optionsnosniff
X-DNS-Prefetch-Controloff
X-Download-Optionsnoopen
X-Frame-OptionsDENY
X-Permitted-Cross-Domain-Policiesnone
X-XSS-Protection0 (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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}

JWT Security​

PropertyValue
AlgorithmHS256
Access token expiry15 minutes
Refresh token expiry7 days
Refresh token storagehttpOnly, Secure, SameSite=Strict cookie
Refresh token rotationYes β€” every refresh issues a new token and revokes the old one
Revocation storeDynamoDB β€” revoked token JTI list with TTL

On each call to POST /api/auth/refresh, the server:

  1. Reads the refresh token from the httpOnly cookie
  2. Verifies the signature and expiry
  3. Checks the token's jti (JWT ID) against the DynamoDB revocation list
  4. Issues a new access token and a new refresh token
  5. Revokes the old refresh token by writing its jti to the revocation list with the original expiry as the DynamoDB TTL

Role-Based Access Control​

Three roles are defined:

RoleAssigned byCapabilities
citizenDefault on registrationReport issues, upvote, comment, join events
officialAdmin assignmentAll citizen actions + assign issues to officials, update status
adminManual DB updateAll 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.