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:
yeuimu
2026-08-22 11:55:13 +08:00
parent 9ed569f5bc
commit 9c1106586a
14 changed files with 240 additions and 112 deletions
+7 -10
View File
@@ -1,16 +1,10 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import {
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import {
LoginResponseDto,
UserPublicDto,
} from './dto/auth-response.dto';
import { LoginResponseDto, UserPublicDto } from './dto/auth-response.dto';
@ApiTags('auth')
@Controller('auth')
@@ -19,15 +13,18 @@ export class AuthController {
@Post('register')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Register a new admin user' })
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: 'Register the first admin user (bootstrap only)' })
@ApiResponse({ status: 201, type: UserPublicDto })
@ApiResponse({ status: 409, description: 'Username already exists' })
@ApiResponse({ status: 403, description: 'Registration is disabled once a user exists' })
register(@Body() dto: RegisterDto): Promise<UserPublicDto> {
return this.authService.register(dto) as unknown as Promise<UserPublicDto>;
}
@Post('login')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: 'Login and obtain a JWT' })
@ApiResponse({ status: 200, type: LoginResponseDto })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
+6 -1
View File
@@ -17,9 +17,14 @@ import { JwtStrategy } from './strategies/jwt.strategy';
if (!secret) {
throw new Error('JWT_SECRET must be configured');
}
if (secret.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters (use a strong random value)');
}
return {
secret,
signOptions: { expiresIn: '7d' },
signOptions: {
expiresIn: config.get<string>('TOKEN_EXPIRES_IN') ?? '7d',
},
};
},
}),
+56 -53
View File
@@ -1,101 +1,104 @@
import { Test } from '@nestjs/testing';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule } from '@nestjs/config';
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { ConflictException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service';
describe('AuthService', () => {
let service: AuthService;
let prisma: PrismaService;
const createdUsernames: string[] = [];
let prisma: {
user: {
count: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
};
};
const HASH = bcrypt.hashSync('plain-pwd', 10);
const dbUser = {
id: 1n,
username: 'alice',
passwordHash: HASH,
createdAt: new Date('2026-01-01T00:00:00Z'),
};
beforeAll(async () => {
prisma = {
user: { count: jest.fn(), findUnique: jest.fn(), create: jest.fn() },
};
const moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
JwtModule.register({
secret: 'test-secret',
secret: 'a'.repeat(32),
signOptions: { expiresIn: '1h' },
}),
],
providers: [AuthService, PrismaService],
providers: [AuthService, { provide: PrismaService, useValue: prisma }],
}).compile();
service = moduleRef.get(AuthService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
});
afterAll(async () => {
// Cleanup created test users
if (createdUsernames.length) {
await prisma.user.deleteMany({
where: { username: { in: createdUsernames } },
});
}
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
beforeEach(() => {
jest.clearAllMocks();
});
describe('register', () => {
it('creates a new user and stores a hashed password', async () => {
const username = `test_reg_${Date.now()}`;
createdUsernames.push(username);
it('creates the first user with a hashed password', async () => {
prisma.user.count.mockResolvedValueOnce(0);
prisma.user.findUnique.mockResolvedValueOnce(null);
prisma.user.create.mockResolvedValueOnce(dbUser);
const user = await service.register({ username, password: 'plain-pwd' });
const user = await service.register({
username: 'alice',
password: 'plain-pwd',
});
expect(user.username).toBe(username);
expect(user.id).toBeTruthy();
expect(user.username).toBe('alice');
const created = prisma.user.create.mock.calls[0][0].data;
expect(created.passwordHash).not.toBe('plain-pwd');
await expect(bcrypt.compare('plain-pwd', created.passwordHash)).resolves.toBe(true);
});
const stored = await prisma.user.findUnique({ where: { username } });
expect(stored).not.toBeNull();
expect(stored?.passwordHash).not.toBe('plain-pwd');
const matches = await bcrypt.compare('plain-pwd', stored!.passwordHash);
expect(matches).toBe(true);
it('refuses registration once a user exists (bootstrap lock)', async () => {
prisma.user.count.mockResolvedValueOnce(1);
await expect(
service.register({ username: 'mallory', password: 'evil-pwd' }),
).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.user.create).not.toHaveBeenCalled();
});
it('throws ConflictException for duplicate usernames', async () => {
const username = `test_dup_${Date.now()}`;
createdUsernames.push(username);
await service.register({ username, password: 'pwd1234' });
prisma.user.count.mockResolvedValueOnce(0);
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
await expect(
service.register({ username, password: 'pwd5678' }),
service.register({ username: 'alice', password: 'pwd5678' }),
).rejects.toBeInstanceOf(ConflictException);
});
});
describe('login', () => {
it('returns an access token for valid credentials', async () => {
const username = `test_login_${Date.now()}`;
createdUsernames.push(username);
await service.register({ username, password: 'correct-pwd' });
const result = await service.login({ username, password: 'correct-pwd' });
expect(result.accessToken).toEqual(expect.any(String));
const parts = result.accessToken.split('.');
expect(parts.length).toBe(3);
expect(result.user.username).toBe(username);
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
const result = await service.login({
username: 'alice',
password: 'plain-pwd',
});
expect(result.accessToken.split('.').length).toBe(3);
expect(result.user.username).toBe('alice');
});
it('throws UnauthorizedException for wrong password', async () => {
const username = `test_wrong_${Date.now()}`;
createdUsernames.push(username);
await service.register({ username, password: 'right-pwd' });
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
await expect(
service.login({ username, password: 'wrong-pwd' }),
service.login({ username: 'alice', password: 'wrong-pwd' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('throws UnauthorizedException for unknown user', async () => {
prisma.user.findUnique.mockResolvedValueOnce(null);
await expect(
service.login({ username: 'no-such-user-xyz', password: 'whatever' }),
service.login({ username: 'no-such-user', password: 'whatever' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
+20 -13
View File
@@ -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,
+18 -4
View File
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../prisma/prisma.service';
/**
* Shape of the JWT we issue.
@@ -15,11 +16,17 @@ export interface JwtPayload {
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
constructor(
config: ConfigService,
private readonly prisma: PrismaService,
) {
const secret = config.get<string>('JWT_SECRET');
if (!secret) {
throw new Error('JWT_SECRET is not configured');
}
if (secret.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters');
}
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
@@ -29,12 +36,19 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
/**
* Runs on every authenticated request. The returned object becomes
* `request.user` for downstream controllers.
* `request.user` for downstream controllers. The user is re-checked in
* the database so tokens of deleted users stop working immediately.
*/
validate(payload: JwtPayload): { id: bigint; username: string } {
async validate(payload: JwtPayload): Promise<{ id: bigint; username: string }> {
if (!payload?.sub || !payload.username) {
throw new UnauthorizedException('Invalid token payload');
}
return { id: BigInt(payload.sub), username: payload.username };
const user = await this.prisma.user
.findUnique({ where: { id: BigInt(payload.sub) } })
.catch(() => null);
if (!user || user.username !== payload.username) {
throw new UnauthorizedException('Invalid token');
}
return { id: user.id, username: user.username };
}
}