Harden security across frontend and backend
All checks were successful
Build & Push Container Image / build (push) Successful in 11s
All checks were successful
Build & Push Container Image / build (push) Successful in 11s
1. AdfRenderer: validate href starts with https?:// before rendering links 2. Logout route: add requireAuth middleware 3. Jira API params: validate sprintId, boardId, issueIdOrKey are alphanumeric 4. CSP header: add Content-Security-Policy with restrictive defaults 5. OAuth callback: align frontendUrl fallback with index.js 6. Rate limiting: express-rate-limit on API routes + Socket.IO event throttling 7. Session KV keys: prefix with cloudId for tenant isolation defense-in-depth 8. saveScopedEstimate: use withSessionCas for atomic read-update-delete Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3ab584e2ab
commit
03ba19042d
8 changed files with 127 additions and 47 deletions
|
|
@ -4,6 +4,7 @@ import { createServer } from 'http';
|
|||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import dotenv from 'dotenv';
|
||||
import { Server } from 'socket.io';
|
||||
import natsAdapter from '@mickl/socket.io-nats-adapter';
|
||||
|
|
@ -53,6 +54,18 @@ app.use((_req, res, next) => {
|
|||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||
res.setHeader('Content-Security-Policy', [
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
`connect-src 'self' wss://${isProd ? new URL(frontendUrl).host : '*'}`,
|
||||
"img-src 'self' https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net https://secure.gravatar.com data:",
|
||||
"font-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'"
|
||||
].join('; '));
|
||||
if (isProd) {
|
||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
|
@ -62,6 +75,7 @@ app.use((_req, res, next) => {
|
|||
app.use(cors(corsOptions));
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use('/api/', rateLimit({ windowMs: 60_000, max: 100, standardHeaders: true, legacyHeaders: false }));
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
|
|
@ -92,8 +106,8 @@ const io = new Server(httpServer, {
|
|||
}
|
||||
});
|
||||
|
||||
async function emitSessionState(sessionId) {
|
||||
const snapshot = await getSessionSnapshot(sessionId);
|
||||
async function emitSessionState(sessionId, tenantCloudId) {
|
||||
const snapshot = await getSessionSnapshot(sessionId, tenantCloudId);
|
||||
if (!snapshot) return;
|
||||
|
||||
io.to(`poker:${sessionId}`).emit('poker:participants', {
|
||||
|
|
@ -123,6 +137,24 @@ async function emitSessionState(sessionId) {
|
|||
}
|
||||
}
|
||||
|
||||
function socketThrottle(socket, limitPerMinute = 60) {
|
||||
const counts = new Map();
|
||||
return (event, handler) => {
|
||||
socket.on(event, async (...args) => {
|
||||
const now = Date.now();
|
||||
const entry = counts.get(event) || { count: 0, resetAt: now + 60_000 };
|
||||
if (now > entry.resetAt) { entry.count = 0; entry.resetAt = now + 60_000; }
|
||||
entry.count++;
|
||||
counts.set(event, entry);
|
||||
if (entry.count > limitPerMinute) {
|
||||
socket.emit('poker:error', { error: 'Too many requests, slow down.' });
|
||||
return;
|
||||
}
|
||||
await handler(...args);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const user = getSocketUser(socket);
|
||||
if (!user || !user.jiraCloudId) {
|
||||
|
|
@ -131,8 +163,9 @@ io.on('connection', (socket) => {
|
|||
return;
|
||||
}
|
||||
socket.user = user;
|
||||
const throttled = socketThrottle(socket);
|
||||
|
||||
socket.on('poker:join', async ({ sessionId }) => {
|
||||
throttled('poker:join', async ({ sessionId }) => {
|
||||
try {
|
||||
if (!sessionId) {
|
||||
socket.emit('poker:error', { error: 'sessionId is required.' });
|
||||
|
|
@ -156,14 +189,14 @@ io.on('connection', (socket) => {
|
|||
socket.emit('poker:error', { error: 'Session not found.' });
|
||||
return;
|
||||
}
|
||||
await emitSessionState(sessionId);
|
||||
await emitSessionState(sessionId, socket.user.jiraCloudId);
|
||||
} catch (error) {
|
||||
console.error('[socket] poker:join failed:', error);
|
||||
socket.emit('poker:error', { error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('poker:vote', async ({ sessionId, vote }) => {
|
||||
throttled('poker:vote', async ({ sessionId, vote }) => {
|
||||
try {
|
||||
if (!sessionId) {
|
||||
socket.emit('poker:error', { error: 'sessionId is required.' });
|
||||
|
|
@ -174,7 +207,7 @@ io.on('connection', (socket) => {
|
|||
socket.emit('poker:error', { error: 'Session not found.' });
|
||||
return;
|
||||
}
|
||||
if (!await isSessionParticipant(sessionId, socket.user.jiraAccountId)) {
|
||||
if (!await isSessionParticipant(sessionId, socket.user.jiraCloudId, socket.user.jiraAccountId)) {
|
||||
socket.emit('poker:error', { error: 'Join the session before voting.' });
|
||||
return;
|
||||
}
|
||||
|
|
@ -189,9 +222,9 @@ io.on('connection', (socket) => {
|
|||
socket.emit('poker:error', { error: 'Unable to submit vote for this session.' });
|
||||
return;
|
||||
}
|
||||
const reveal = await revealIfComplete(sessionId);
|
||||
const reveal = await revealIfComplete(sessionId, socket.user.jiraCloudId);
|
||||
|
||||
await emitSessionState(sessionId);
|
||||
await emitSessionState(sessionId, socket.user.jiraCloudId);
|
||||
|
||||
if (reveal?.allVoted) {
|
||||
io.to(`poker:${sessionId}`).emit('poker:revealed', {
|
||||
|
|
@ -207,7 +240,7 @@ io.on('connection', (socket) => {
|
|||
}
|
||||
});
|
||||
|
||||
socket.on('poker:save', async ({ sessionId, estimate }) => {
|
||||
throttled('poker:save', async ({ sessionId, estimate }) => {
|
||||
try {
|
||||
if (!sessionId) {
|
||||
socket.emit('poker:error', { error: 'sessionId is required.' });
|
||||
|
|
@ -218,7 +251,7 @@ io.on('connection', (socket) => {
|
|||
socket.emit('poker:error', { error: 'Session not found.' });
|
||||
return;
|
||||
}
|
||||
if (!await isSessionParticipant(sessionId, socket.user.jiraAccountId)) {
|
||||
if (!await isSessionParticipant(sessionId, socket.user.jiraCloudId, socket.user.jiraAccountId)) {
|
||||
socket.emit('poker:error', { error: 'Join the session before saving.' });
|
||||
return;
|
||||
}
|
||||
|
|
@ -260,7 +293,7 @@ io.on('connection', (socket) => {
|
|||
}
|
||||
});
|
||||
|
||||
socket.on('poker:leave', async ({ sessionId }) => {
|
||||
throttled('poker:leave', async ({ sessionId }) => {
|
||||
try {
|
||||
if (!sessionId) return;
|
||||
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) return;
|
||||
|
|
@ -270,7 +303,7 @@ io.on('connection', (socket) => {
|
|||
userKey: socket.user.jiraAccountId
|
||||
});
|
||||
socket.leave(`poker:${sessionId}`);
|
||||
await emitSessionState(sessionId);
|
||||
await emitSessionState(sessionId, socket.user.jiraCloudId);
|
||||
} catch (error) {
|
||||
console.error('[socket] poker:leave failed:', error);
|
||||
socket.emit('poker:error', { error: safeError(error) });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue