Security hardening: JWT algorithm pinning, key separation, injection fixes
- Pin HS256 algorithm on all jwt.verify() calls (9 sites) and jwt.sign() calls (3 sites) — prevents algorithm confusion attacks - Add JWT_INVITE_SECRET env var; volunteer invite tokens now use a dedicated key separate from access/refresh secrets - Remove req.query.secret fallback from Listmonk webhook route — secrets must not appear in nginx access logs - Replace child_process.spawn in email template seed endpoint with direct function import; add require.main guard to seed script - Add sanitizeCsvField() to location CSV export to prevent formula injection in Excel/Sheets (=, +, -, @ prefix → apostrophe prefix) - Cap QR endpoint text input at 2000 chars to prevent DoS via large payloads - Fix pre-existing TS errors: type participantNeeds as UpsertNeedsInput in meeting-planner service; add sso field to UpdateResourcePayload Bunker Admin
This commit is contained in:
parent
15fb9b93aa
commit
647efffdc4
@ -29,6 +29,7 @@ V2_POSTGRES_PORT=5433
|
|||||||
# --- JWT Auth ---
|
# --- JWT Auth ---
|
||||||
JWT_ACCESS_SECRET=GENERATE_WITH_openssl_rand_hex_32
|
JWT_ACCESS_SECRET=GENERATE_WITH_openssl_rand_hex_32
|
||||||
JWT_REFRESH_SECRET=GENERATE_WITH_openssl_rand_hex_32
|
JWT_REFRESH_SECRET=GENERATE_WITH_openssl_rand_hex_32
|
||||||
|
JWT_INVITE_SECRET=GENERATE_WITH_openssl_rand_hex_32
|
||||||
JWT_ACCESS_EXPIRY=15m
|
JWT_ACCESS_EXPIRY=15m
|
||||||
JWT_REFRESH_EXPIRY=7d
|
JWT_REFRESH_EXPIRY=7d
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,7 @@ const envSchema = z.object({
|
|||||||
// JWT
|
// JWT
|
||||||
JWT_ACCESS_SECRET: z.string().min(32),
|
JWT_ACCESS_SECRET: z.string().min(32),
|
||||||
JWT_REFRESH_SECRET: z.string().min(32),
|
JWT_REFRESH_SECRET: z.string().min(32),
|
||||||
|
JWT_INVITE_SECRET: z.string().min(32),
|
||||||
JWT_ACCESS_EXPIRY: z.string().default('15m'),
|
JWT_ACCESS_EXPIRY: z.string().default('15m'),
|
||||||
JWT_REFRESH_EXPIRY: z.string().default('7d'),
|
JWT_REFRESH_EXPIRY: z.string().default('7d'),
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export function authenticate(req: Request, _res: Response, next: NextFunction) {
|
|||||||
const token = header.slice(7);
|
const token = header.slice(7);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
|
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
|
||||||
req.user = {
|
req.user = {
|
||||||
id: payload.id,
|
id: payload.id,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
@ -43,7 +43,7 @@ export function optionalAuth(req: Request, _res: Response, next: NextFunction) {
|
|||||||
const token = header.slice(7);
|
const token = header.slice(7);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
|
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
|
||||||
req.user = {
|
req.user = {
|
||||||
id: payload.id,
|
id: payload.id,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
|
|||||||
@ -201,7 +201,7 @@ export const authService = {
|
|||||||
async refreshTokens(refreshToken: string) {
|
async refreshTokens(refreshToken: string) {
|
||||||
let payload: TokenPayload;
|
let payload: TokenPayload;
|
||||||
try {
|
try {
|
||||||
payload = jwt.verify(refreshToken, env.JWT_REFRESH_SECRET) as TokenPayload;
|
payload = jwt.verify(refreshToken, env.JWT_REFRESH_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
|
||||||
} catch {
|
} catch {
|
||||||
throw new AppError(401, 'Invalid refresh token', 'INVALID_REFRESH_TOKEN');
|
throw new AppError(401, 'Invalid refresh token', 'INVALID_REFRESH_TOKEN');
|
||||||
}
|
}
|
||||||
@ -245,6 +245,7 @@ export const authService = {
|
|||||||
roles: userRoles,
|
roles: userRoles,
|
||||||
};
|
};
|
||||||
const refreshToken = jwt.sign(refreshPayload, env.JWT_REFRESH_SECRET, {
|
const refreshToken = jwt.sign(refreshPayload, env.JWT_REFRESH_SECRET, {
|
||||||
|
algorithm: 'HS256',
|
||||||
expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'],
|
expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -280,6 +281,7 @@ export const authService = {
|
|||||||
roles: userRoles,
|
roles: userRoles,
|
||||||
};
|
};
|
||||||
return jwt.sign(payload, env.JWT_ACCESS_SECRET, {
|
return jwt.sign(payload, env.JWT_ACCESS_SECRET, {
|
||||||
|
algorithm: 'HS256',
|
||||||
expiresIn: env.JWT_ACCESS_EXPIRY as SignOptions['expiresIn'],
|
expiresIn: env.JWT_ACCESS_EXPIRY as SignOptions['expiresIn'],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -293,6 +295,7 @@ export const authService = {
|
|||||||
roles: userRoles,
|
roles: userRoles,
|
||||||
};
|
};
|
||||||
const token = jwt.sign(payload, env.JWT_REFRESH_SECRET, {
|
const token = jwt.sign(payload, env.JWT_REFRESH_SECRET, {
|
||||||
|
algorithm: 'HS256',
|
||||||
expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'],
|
expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -74,7 +74,7 @@ const docsExtension: Extension = {
|
|||||||
// Verify JWT
|
// Verify JWT
|
||||||
let payload: TokenPayload;
|
let payload: TokenPayload;
|
||||||
try {
|
try {
|
||||||
payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
|
payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Invalid or expired token');
|
throw new Error('Invalid or expired token');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { BROADCAST_ROLES } from '../../utils/roles';
|
|||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import RedisStore from 'rate-limit-redis';
|
import RedisStore from 'rate-limit-redis';
|
||||||
import { redis } from '../../config/redis';
|
import { redis } from '../../config/redis';
|
||||||
|
import { seedEmailTemplates } from '../../scripts/seed-email-templates';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -314,26 +315,7 @@ router.post(
|
|||||||
requireRole(UserRole.SUPER_ADMIN),
|
requireRole(UserRole.SUPER_ADMIN),
|
||||||
async (req: Request, res: Response): Promise<void> => {
|
async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// This is a placeholder - the actual seeding is done via the script
|
await seedEmailTemplates();
|
||||||
// But we keep this endpoint for manual triggering if needed
|
|
||||||
const { spawn } = require('child_process');
|
|
||||||
const child = spawn('npx', ['tsx', 'src/scripts/seed-email-templates.ts'], {
|
|
||||||
cwd: '/app',
|
|
||||||
shell: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let exitCode = 0;
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
child.on('close', (code: number) => {
|
|
||||||
exitCode = code;
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (exitCode !== 0) {
|
|
||||||
throw new Error(`Seed script exited with code ${exitCode}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('Email templates seeded via API');
|
logger.info('Email templates seeded via API');
|
||||||
res.json({ success: true, message: 'Templates seeded successfully' });
|
res.json({ success: true, message: 'Templates seeded successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -6,17 +6,17 @@ import { logger } from '../../utils/logger';
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/listmonk/webhook?secret=...
|
* POST /api/listmonk/webhook
|
||||||
*
|
*
|
||||||
* Handles Listmonk webhook events for reverse sync (e.g., unsubscribes).
|
* Handles Listmonk webhook events for reverse sync (e.g., unsubscribes).
|
||||||
* Validates a shared secret query parameter. No JWT auth — Listmonk calls this.
|
* Validates a shared secret via x-webhook-secret header. No JWT auth — Listmonk calls this.
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/webhook',
|
'/webhook',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
// Accept secret from header (preferred) or query param (legacy fallback)
|
// Accept secret from header only (query param removed — secrets must not appear in logs)
|
||||||
const secret = (req.headers['x-webhook-secret'] as string) || (req.query.secret as string);
|
const secret = req.headers['x-webhook-secret'] as string;
|
||||||
if (!env.LISTMONK_WEBHOOK_SECRET || secret !== env.LISTMONK_WEBHOOK_SECRET) {
|
if (!env.LISTMONK_WEBHOOK_SECRET || secret !== env.LISTMONK_WEBHOOK_SECRET) {
|
||||||
res.status(403).json({ error: 'Invalid webhook secret' });
|
res.status(403).json({ error: 'Invalid webhook secret' });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -1253,6 +1253,14 @@ export const locationsService = {
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sanitize a field value against CSV formula injection.
|
||||||
|
// Spreadsheet apps treat cells starting with =, +, -, @, \t, \r as formulas.
|
||||||
|
// Prefixing with an apostrophe causes Excel/Sheets to treat the value as plain text.
|
||||||
|
function sanitizeCsvField(value: string): string {
|
||||||
|
if (/^[=+\-@\t\r]/.test(value)) return `'${value}`;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
// Flatten: one row per address
|
// Flatten: one row per address
|
||||||
const rows: Array<Record<string, string>> = [];
|
const rows: Array<Record<string, string>> = [];
|
||||||
for (const loc of locations) {
|
for (const loc of locations) {
|
||||||
@ -1262,16 +1270,16 @@ export const locationsService = {
|
|||||||
if (filters?.hasSign !== undefined && addr.sign !== filters.hasSign) continue;
|
if (filters?.hasSign !== undefined && addr.sign !== filters.hasSign) continue;
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
address: loc.address || '',
|
address: sanitizeCsvField(loc.address || ''),
|
||||||
unitNumber: addr.unitNumber || '',
|
unitNumber: sanitizeCsvField(addr.unitNumber || ''),
|
||||||
firstName: addr.firstName || '',
|
firstName: sanitizeCsvField(addr.firstName || ''),
|
||||||
lastName: addr.lastName || '',
|
lastName: sanitizeCsvField(addr.lastName || ''),
|
||||||
email: addr.email || '',
|
email: sanitizeCsvField(addr.email || ''),
|
||||||
phone: addr.phone || '',
|
phone: sanitizeCsvField(addr.phone || ''),
|
||||||
supportLevel: addr.supportLevel || '',
|
supportLevel: addr.supportLevel || '',
|
||||||
sign: addr.sign ? 'Yes' : 'No',
|
sign: addr.sign ? 'Yes' : 'No',
|
||||||
signSize: addr.signSize || '',
|
signSize: addr.signSize || '',
|
||||||
notes: addr.notes || '',
|
notes: sanitizeCsvField(addr.notes || ''),
|
||||||
latitude: loc.latitude?.toString() || '',
|
latitude: loc.latitude?.toString() || '',
|
||||||
longitude: loc.longitude?.toString() || '',
|
longitude: loc.longitude?.toString() || '',
|
||||||
geocodeConfidence: loc.geocodeConfidence?.toString() || '',
|
geocodeConfidence: loc.geocodeConfidence?.toString() || '',
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export async function authenticate(
|
|||||||
// Verify JWT with V2 access secret
|
// Verify JWT with V2 access secret
|
||||||
let payload: TokenPayload;
|
let payload: TokenPayload;
|
||||||
try {
|
try {
|
||||||
payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
|
payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return reply.status(401).send({
|
return reply.status(401).send({
|
||||||
error: 'Invalid or expired token',
|
error: 'Invalid or expired token',
|
||||||
@ -154,7 +154,7 @@ export async function optionalAuth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
|
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
|
||||||
|
|
||||||
// Verify user exists and is active
|
// Verify user exists and is active
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
|
|||||||
@ -63,7 +63,7 @@ export async function chatNotificationsRoutes(fastify: FastifyInstance) {
|
|||||||
// Verify JWT
|
// Verify JWT
|
||||||
let payload: TokenPayload;
|
let payload: TokenPayload;
|
||||||
try {
|
try {
|
||||||
payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
|
payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
|
||||||
} catch {
|
} catch {
|
||||||
return reply.code(401).send({ message: 'Invalid or expired token' });
|
return reply.code(401).send({ message: 'Invalid or expired token' });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,7 @@ async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
|
|||||||
|
|
||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
|
|
||||||
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as {
|
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as {
|
||||||
id: string;
|
id: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
roles?: UserRole[];
|
roles?: UserRole[];
|
||||||
|
|||||||
@ -30,7 +30,7 @@ async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
|
|||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
|
|
||||||
// Verify JWT signature
|
// Verify JWT signature
|
||||||
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as {
|
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as {
|
||||||
id: string;
|
id: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
roles?: UserRole[];
|
roles?: UserRole[];
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { generateSlug } from '../../utils/slug';
|
|||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { pollAutoFinalizeQueueService } from '../../services/poll-auto-finalize-queue.service';
|
import { pollAutoFinalizeQueueService } from '../../services/poll-auto-finalize-queue.service';
|
||||||
import { participantNeedsService } from '../people/participant-needs.service';
|
import { participantNeedsService } from '../people/participant-needs.service';
|
||||||
|
import type { UpsertNeedsInput } from '../people/participant-needs.schemas';
|
||||||
import type {
|
import type {
|
||||||
CreatePollInput,
|
CreatePollInput,
|
||||||
UpdatePollInput,
|
UpdatePollInput,
|
||||||
@ -895,7 +896,7 @@ export const meetingPlannerService = {
|
|||||||
optionIds: string[],
|
optionIds: string[],
|
||||||
userId?: string,
|
userId?: string,
|
||||||
voterToken?: string,
|
voterToken?: string,
|
||||||
participantNeeds?: Record<string, any>,
|
participantNeeds?: UpsertNeedsInput,
|
||||||
) {
|
) {
|
||||||
const normalizedEmail = voterEmail.trim().toLowerCase();
|
const normalizedEmail = voterEmail.trim().toLowerCase();
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,10 @@ router.get('/', async (req, res, next) => {
|
|||||||
res.status(400).json({ error: { message: 'text parameter is required' } });
|
res.status(400).json({ error: { message: 'text parameter is required' } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (text.length > 2000) {
|
||||||
|
res.status(400).json({ error: { message: 'text parameter must be 2000 characters or fewer' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const rawSize = parseInt(req.query.size as string, 10);
|
const rawSize = parseInt(req.query.size as string, 10);
|
||||||
const size = Math.min(500, Math.max(50, isNaN(rawSize) ? 200 : rawSize));
|
const size = Math.min(500, Math.max(50, isNaN(rawSize) ? 200 : rawSize));
|
||||||
|
|||||||
@ -28,7 +28,7 @@ export const volunteerInviteService = {
|
|||||||
...(shiftId && { shiftId }),
|
...(shiftId && { shiftId }),
|
||||||
};
|
};
|
||||||
|
|
||||||
return jwt.sign(payload, env.JWT_ACCESS_SECRET, { expiresIn: '30m' });
|
return jwt.sign(payload, env.JWT_INVITE_SECRET, { algorithm: 'HS256', expiresIn: '30m' });
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -39,7 +39,7 @@ export const volunteerInviteService = {
|
|||||||
// 1. Verify and decode the invite token
|
// 1. Verify and decode the invite token
|
||||||
let payload: InviteTokenPayload;
|
let payload: InviteTokenPayload;
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(input.token, env.JWT_ACCESS_SECRET);
|
const decoded = jwt.verify(input.token, env.JWT_INVITE_SECRET, { algorithms: ['HS256'] });
|
||||||
payload = decoded as InviteTokenPayload;
|
payload = decoded as InviteTokenPayload;
|
||||||
} catch {
|
} catch {
|
||||||
throw new AppError(400, 'Invalid or expired invite link', 'INVALID_INVITE_TOKEN');
|
throw new AppError(400, 'Invalid or expired invite link', 'INVALID_INVITE_TOKEN');
|
||||||
|
|||||||
@ -10,8 +10,6 @@ import { PrismaClient, EmailTemplateCategory } from '@prisma/client';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
interface TemplateDefinition {
|
interface TemplateDefinition {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -168,6 +166,7 @@ const TEMPLATES: TemplateDefinition[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
async function seedEmailTemplates() {
|
async function seedEmailTemplates() {
|
||||||
|
const prisma = new PrismaClient();
|
||||||
console.log('🌱 Starting email template seeding...\n');
|
console.log('🌱 Starting email template seeding...\n');
|
||||||
|
|
||||||
// Find or create SUPER_ADMIN user for created_by
|
// Find or create SUPER_ADMIN user for created_by
|
||||||
@ -281,13 +280,17 @@ async function seedEmailTemplates() {
|
|||||||
console.log(` Skipped: ${skippedCount}`);
|
console.log(` Skipped: ${skippedCount}`);
|
||||||
console.log(` Errors: ${errorCount}`);
|
console.log(` Errors: ${errorCount}`);
|
||||||
console.log('═'.repeat(60));
|
console.log('═'.repeat(60));
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
seedEmailTemplates()
|
export { seedEmailTemplates };
|
||||||
.catch((error) => {
|
|
||||||
console.error('Fatal error:', error);
|
// Only auto-execute when run directly (not when imported as a module)
|
||||||
process.exit(1);
|
if (require.main === module) {
|
||||||
})
|
seedEmailTemplates()
|
||||||
.finally(async () => {
|
.catch((error) => {
|
||||||
await prisma.$disconnect();
|
console.error('Fatal error:', error);
|
||||||
});
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -82,7 +82,7 @@ export function startProxy(): http.Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as {
|
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as {
|
||||||
id: string;
|
id: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
roles?: UserRole[];
|
roles?: UserRole[];
|
||||||
|
|||||||
@ -110,6 +110,7 @@ export interface UpdateResourcePayload {
|
|||||||
subdomain?: string;
|
subdomain?: string;
|
||||||
fullDomain?: string;
|
fullDomain?: string;
|
||||||
ssl?: boolean;
|
ssl?: boolean;
|
||||||
|
sso?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
blockAccess?: boolean;
|
blockAccess?: boolean;
|
||||||
proxyPort?: number;
|
proxyPort?: number;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user