fix(security): harden auth, upload, and API configuration
- Lock public registration to first-user bootstrap (403 afterwards) - Require JwtAuthGuard on upload + whitelist png/jpg/webp/gif (SVG/XSS blocked) - Add global throttling (login/register 5/min, upload 10/min) - Add helmet security headers; serve uploads with nosniff - Replace permissive CORS (origin:true+credentials) with CORS_ORIGINS whitelist - Disable Swagger outside development; sanitize 500 error responses - Enforce 32+ char JWT_SECRET; make token expiry configurable (TOKEN_EXPIRES_IN) - Re-check user in DB on every JWT validation (revocation on user delete) - Dummy bcrypt compare to prevent login user-enumeration via timing - Map malformed BigInt inputs to 400 instead of 500 - Widen .gitignore to .env* and add apps/api/.env.example - Disable Nuxt devtools and sourcemaps
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
@@ -22,7 +23,13 @@ export interface LoginResult {
|
||||
}
|
||||
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
const TOKEN_EXPIRES_IN = '7d';
|
||||
const TOKEN_EXPIRES_IN = process.env.TOKEN_EXPIRES_IN ?? '7d';
|
||||
|
||||
/**
|
||||
* Compared against when the username does not exist so that login takes
|
||||
* the same time either way (prevents user enumeration via timing).
|
||||
*/
|
||||
const DUMMY_HASH = '$2b$10$l232BFW3u63Mhfx0BatxUOLtw.qEofG9fNYjLsh2zce7MdIKDAIR6';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -32,10 +39,15 @@ export class AuthService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Registers a brand-new admin user. Throws {@link ConflictException}
|
||||
* if the username is already taken.
|
||||
* Bootstrap-only registration: allowed just while the instance has no
|
||||
* users. Once an admin exists the endpoint refuses to create accounts
|
||||
* (use database seeding / an operator flow instead).
|
||||
*/
|
||||
async register(dto: RegisterDto): Promise<PublicUser> {
|
||||
const userCount = await this.prisma.user.count();
|
||||
if (userCount > 0) {
|
||||
throw new ForbiddenException('Registration is disabled');
|
||||
}
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { username: dto.username },
|
||||
});
|
||||
@@ -56,11 +68,10 @@ export class AuthService {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { username: dto.username },
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!ok) {
|
||||
// Always run a bcrypt compare (against a dummy hash when the user is
|
||||
// unknown) so response timing cannot be used to enumerate usernames.
|
||||
const ok = await bcrypt.compare(dto.password, user?.passwordHash ?? DUMMY_HASH);
|
||||
if (!user || !ok) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
const payload: JwtPayload = {
|
||||
@@ -73,11 +84,7 @@ export class AuthService {
|
||||
return { accessToken, user: this.toPublic(user) };
|
||||
}
|
||||
|
||||
private toPublic(user: {
|
||||
id: bigint;
|
||||
username: string;
|
||||
createdAt: Date;
|
||||
}): PublicUser {
|
||||
private toPublic(user: { id: bigint; username: string; createdAt: Date }): PublicUser {
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
username: user.username,
|
||||
|
||||
Reference in New Issue
Block a user