merge: security hardening fixes
This commit is contained in:
+2
-4
@@ -22,10 +22,8 @@ yarn-error.log*
|
|||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env*
|
||||||
.env.local
|
!.env.example
|
||||||
.env.development
|
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Prisma connection string (PostgreSQL)
|
||||||
|
DATABASE_URL=postgresql://postgres:CHANGE_ME@localhost:5432/inkreach-official-website
|
||||||
|
|
||||||
|
# JWT signing secret: generate with `node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"`
|
||||||
|
# Must be at least 32 characters.
|
||||||
|
JWT_SECRET=CHANGE_ME_TO_A_STRONG_RANDOM_SECRET
|
||||||
|
|
||||||
|
# Access token lifetime (jwt-rest compatible, e.g. 30m, 12h, 7d)
|
||||||
|
TOKEN_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# Comma-separated list of allowed CORS origins (leave empty to disable CORS)
|
||||||
|
CORS_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
|
# Global rate limit per minute (per IP)
|
||||||
|
THROTTLE_LIMIT=120
|
||||||
|
|
||||||
|
PORT=3001
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"@nestjs/platform-express": "^10.3.0",
|
"@nestjs/platform-express": "^10.3.0",
|
||||||
"@nestjs/schedule": "^4.0.0",
|
"@nestjs/schedule": "^4.0.0",
|
||||||
"@nestjs/swagger": "^7.1.17",
|
"@nestjs/swagger": "^7.1.17",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/client": "^5.8.0",
|
"@prisma/client": "^5.8.0",
|
||||||
"@types/multer": "^2.2.0",
|
"@types/multer": "^2.2.0",
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.6.5",
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
|
"helmet": "^8.3.0",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { CountriesModule } from './countries/countries.module';
|
import { CountriesModule } from './countries/countries.module';
|
||||||
@@ -18,6 +20,14 @@ import { UploadModule } from './upload/upload.module';
|
|||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
}),
|
}),
|
||||||
|
// Global rate limiting: 120 req/min per IP. Stricter limits are set
|
||||||
|
// per-endpoint with @Throttle (auth, upload).
|
||||||
|
ThrottlerModule.forRoot([
|
||||||
|
{
|
||||||
|
ttl: 60_000,
|
||||||
|
limit: Number(process.env.THROTTLE_LIMIT ?? 120),
|
||||||
|
},
|
||||||
|
]),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
CountriesModule,
|
CountriesModule,
|
||||||
@@ -31,5 +41,11 @@ import { UploadModule } from './upload/upload.module';
|
|||||||
PublicModule,
|
PublicModule,
|
||||||
UploadModule,
|
UploadModule,
|
||||||
],
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
@@ -1,16 +1,10 @@
|
|||||||
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||||
import {
|
import { Throttle } from '@nestjs/throttler';
|
||||||
ApiOperation,
|
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
ApiResponse,
|
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import {
|
import { LoginResponseDto, UserPublicDto } from './dto/auth-response.dto';
|
||||||
LoginResponseDto,
|
|
||||||
UserPublicDto,
|
|
||||||
} from './dto/auth-response.dto';
|
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@@ -19,15 +13,18 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@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: 201, type: UserPublicDto })
|
||||||
@ApiResponse({ status: 409, description: 'Username already exists' })
|
@ApiResponse({ status: 409, description: 'Username already exists' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Registration is disabled once a user exists' })
|
||||||
register(@Body() dto: RegisterDto): Promise<UserPublicDto> {
|
register(@Body() dto: RegisterDto): Promise<UserPublicDto> {
|
||||||
return this.authService.register(dto) as unknown as Promise<UserPublicDto>;
|
return this.authService.register(dto) as unknown as Promise<UserPublicDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||||
@ApiOperation({ summary: 'Login and obtain a JWT' })
|
@ApiOperation({ summary: 'Login and obtain a JWT' })
|
||||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||||
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||||
|
|||||||
@@ -17,9 +17,14 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
|||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new Error('JWT_SECRET must be configured');
|
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 {
|
return {
|
||||||
secret,
|
secret,
|
||||||
signOptions: { expiresIn: '7d' },
|
signOptions: {
|
||||||
|
expiresIn: config.get<string>('TOKEN_EXPIRES_IN') ?? '7d',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,101 +1,104 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConflictException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
let service: AuthService;
|
let service: AuthService;
|
||||||
let prisma: PrismaService;
|
let prisma: {
|
||||||
const createdUsernames: string[] = [];
|
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 () => {
|
beforeAll(async () => {
|
||||||
|
prisma = {
|
||||||
|
user: { count: jest.fn(), findUnique: jest.fn(), create: jest.fn() },
|
||||||
|
};
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: 'test-secret',
|
secret: 'a'.repeat(32),
|
||||||
signOptions: { expiresIn: '1h' },
|
signOptions: { expiresIn: '1h' },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
providers: [AuthService, PrismaService],
|
providers: [AuthService, { provide: PrismaService, useValue: prisma }],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = moduleRef.get(AuthService);
|
service = moduleRef.get(AuthService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
|
||||||
await prisma.onModuleInit();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
beforeEach(() => {
|
||||||
// Cleanup created test users
|
jest.clearAllMocks();
|
||||||
if (createdUsernames.length) {
|
|
||||||
await prisma.user.deleteMany({
|
|
||||||
where: { username: { in: createdUsernames } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await prisma.onModuleDestroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be defined', () => {
|
|
||||||
expect(service).toBeDefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('register', () => {
|
describe('register', () => {
|
||||||
it('creates a new user and stores a hashed password', async () => {
|
it('creates the first user with a hashed password', async () => {
|
||||||
const username = `test_reg_${Date.now()}`;
|
prisma.user.count.mockResolvedValueOnce(0);
|
||||||
createdUsernames.push(username);
|
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.username).toBe('alice');
|
||||||
expect(user.id).toBeTruthy();
|
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 } });
|
it('refuses registration once a user exists (bootstrap lock)', async () => {
|
||||||
expect(stored).not.toBeNull();
|
prisma.user.count.mockResolvedValueOnce(1);
|
||||||
expect(stored?.passwordHash).not.toBe('plain-pwd');
|
await expect(
|
||||||
const matches = await bcrypt.compare('plain-pwd', stored!.passwordHash);
|
service.register({ username: 'mallory', password: 'evil-pwd' }),
|
||||||
expect(matches).toBe(true);
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws ConflictException for duplicate usernames', async () => {
|
it('throws ConflictException for duplicate usernames', async () => {
|
||||||
const username = `test_dup_${Date.now()}`;
|
prisma.user.count.mockResolvedValueOnce(0);
|
||||||
createdUsernames.push(username);
|
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
|
||||||
|
|
||||||
await service.register({ username, password: 'pwd1234' });
|
|
||||||
await expect(
|
await expect(
|
||||||
service.register({ username, password: 'pwd5678' }),
|
service.register({ username: 'alice', password: 'pwd5678' }),
|
||||||
).rejects.toBeInstanceOf(ConflictException);
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('login', () => {
|
describe('login', () => {
|
||||||
it('returns an access token for valid credentials', async () => {
|
it('returns an access token for valid credentials', async () => {
|
||||||
const username = `test_login_${Date.now()}`;
|
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
|
||||||
createdUsernames.push(username);
|
const result = await service.login({
|
||||||
await service.register({ username, password: 'correct-pwd' });
|
username: 'alice',
|
||||||
|
password: 'plain-pwd',
|
||||||
const result = await service.login({ username, password: 'correct-pwd' });
|
});
|
||||||
expect(result.accessToken).toEqual(expect.any(String));
|
expect(result.accessToken.split('.').length).toBe(3);
|
||||||
const parts = result.accessToken.split('.');
|
expect(result.user.username).toBe('alice');
|
||||||
expect(parts.length).toBe(3);
|
|
||||||
expect(result.user.username).toBe(username);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws UnauthorizedException for wrong password', async () => {
|
it('throws UnauthorizedException for wrong password', async () => {
|
||||||
const username = `test_wrong_${Date.now()}`;
|
prisma.user.findUnique.mockResolvedValueOnce(dbUser);
|
||||||
createdUsernames.push(username);
|
|
||||||
await service.register({ username, password: 'right-pwd' });
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.login({ username, password: 'wrong-pwd' }),
|
service.login({ username: 'alice', password: 'wrong-pwd' }),
|
||||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws UnauthorizedException for unknown user', async () => {
|
it('throws UnauthorizedException for unknown user', async () => {
|
||||||
|
prisma.user.findUnique.mockResolvedValueOnce(null);
|
||||||
await expect(
|
await expect(
|
||||||
service.login({ username: 'no-such-user-xyz', password: 'whatever' }),
|
service.login({ username: 'no-such-user', password: 'whatever' }),
|
||||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -22,7 +23,13 @@ export interface LoginResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BCRYPT_ROUNDS = 10;
|
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()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -32,10 +39,15 @@ export class AuthService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a brand-new admin user. Throws {@link ConflictException}
|
* Bootstrap-only registration: allowed just while the instance has no
|
||||||
* if the username is already taken.
|
* users. Once an admin exists the endpoint refuses to create accounts
|
||||||
|
* (use database seeding / an operator flow instead).
|
||||||
*/
|
*/
|
||||||
async register(dto: RegisterDto): Promise<PublicUser> {
|
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({
|
const existing = await this.prisma.user.findUnique({
|
||||||
where: { username: dto.username },
|
where: { username: dto.username },
|
||||||
});
|
});
|
||||||
@@ -56,11 +68,10 @@ export class AuthService {
|
|||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { username: dto.username },
|
where: { username: dto.username },
|
||||||
});
|
});
|
||||||
if (!user) {
|
// Always run a bcrypt compare (against a dummy hash when the user is
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
// unknown) so response timing cannot be used to enumerate usernames.
|
||||||
}
|
const ok = await bcrypt.compare(dto.password, user?.passwordHash ?? DUMMY_HASH);
|
||||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
if (!user || !ok) {
|
||||||
if (!ok) {
|
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
}
|
}
|
||||||
const payload: JwtPayload = {
|
const payload: JwtPayload = {
|
||||||
@@ -73,11 +84,7 @@ export class AuthService {
|
|||||||
return { accessToken, user: this.toPublic(user) };
|
return { accessToken, user: this.toPublic(user) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private toPublic(user: {
|
private toPublic(user: { id: bigint; username: string; createdAt: Date }): PublicUser {
|
||||||
id: bigint;
|
|
||||||
username: string;
|
|
||||||
createdAt: Date;
|
|
||||||
}): PublicUser {
|
|
||||||
return {
|
return {
|
||||||
id: user.id.toString(),
|
id: user.id.toString(),
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shape of the JWT we issue.
|
* Shape of the JWT we issue.
|
||||||
@@ -15,11 +16,17 @@ export interface JwtPayload {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(config: ConfigService) {
|
constructor(
|
||||||
|
config: ConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {
|
||||||
const secret = config.get<string>('JWT_SECRET');
|
const secret = config.get<string>('JWT_SECRET');
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new Error('JWT_SECRET is not configured');
|
throw new Error('JWT_SECRET is not configured');
|
||||||
}
|
}
|
||||||
|
if (secret.length < 32) {
|
||||||
|
throw new Error('JWT_SECRET must be at least 32 characters');
|
||||||
|
}
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
@@ -29,12 +36,19 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs on every authenticated request. The returned object becomes
|
* 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) {
|
if (!payload?.sub || !payload.username) {
|
||||||
throw new UnauthorizedException('Invalid token payload');
|
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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,24 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
const request = ctx.getRequest<Request>();
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
const status =
|
const status =
|
||||||
exception instanceof HttpException
|
exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
? exception.getStatus()
|
|
||||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
// Malformed bigint/number inputs (e.g. `BigInt("abc")`) are client
|
||||||
|
// errors — map them to 400 instead of leaking a 500.
|
||||||
|
if (
|
||||||
|
status === HttpStatus.INTERNAL_SERVER_ERROR &&
|
||||||
|
exception instanceof Error &&
|
||||||
|
/Cannot convert .+ to (a BigInt|number)/i.test(exception.message)
|
||||||
|
) {
|
||||||
|
response.status(HttpStatus.BAD_REQUEST).json({
|
||||||
|
statusCode: HttpStatus.BAD_REQUEST,
|
||||||
|
message: 'Invalid numeric identifier',
|
||||||
|
error: 'BadRequestError',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let message: string | string[] = 'Internal server error';
|
let message: string | string[] = 'Internal server error';
|
||||||
let error = 'InternalServerError';
|
let error = 'InternalServerError';
|
||||||
@@ -51,11 +66,15 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
message = exception.message;
|
message = exception.message;
|
||||||
}
|
}
|
||||||
} else if (exception instanceof Error) {
|
} else if (exception instanceof Error) {
|
||||||
message = exception.message;
|
// Unexpected errors (Prisma, driver, ...) may contain SQL or
|
||||||
error = exception.name;
|
// connection details — never send them to the client.
|
||||||
|
this.logger.error(
|
||||||
|
`${request.method} ${request.url} -> ${status} ${exception.message}`,
|
||||||
|
exception.stack,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status >= 500) {
|
if (status >= 500 && exception instanceof HttpException) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`${request.method} ${request.url} -> ${status} ${message}`,
|
`${request.method} ${request.url} -> ${status} ${message}`,
|
||||||
exception instanceof Error ? exception.stack : undefined,
|
exception instanceof Error ? exception.stack : undefined,
|
||||||
|
|||||||
+28
-16
@@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import helmet from 'helmet';
|
||||||
import { json } from 'express';
|
import { json } from 'express';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
@@ -28,15 +29,24 @@ async function bootstrap() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// CORS
|
// Security headers (X-Content-Type-Options, X-Frame-Options, CSP, HSTS, ...)
|
||||||
app.enableCors({
|
app.use(helmet());
|
||||||
origin: true,
|
|
||||||
credentials: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Serve uploaded files
|
// CORS: only origins listed in CORS_ORIGINS (comma-separated) are allowed.
|
||||||
|
// Authentication uses Bearer headers, so credentialed CORS is not needed.
|
||||||
|
const corsOrigins = (process.env.CORS_ORIGINS ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((o) => o.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
app.enableCors(corsOrigins.length > 0 ? { origin: corsOrigins } : undefined);
|
||||||
|
|
||||||
|
// Serve uploaded files. nosniff prevents browsers from sniffing a
|
||||||
|
// non-image content type out of an uploaded file.
|
||||||
app.useStaticAssets(join(process.cwd(), 'uploads'), {
|
app.useStaticAssets(join(process.cwd(), 'uploads'), {
|
||||||
prefix: '/uploads/',
|
prefix: '/uploads/',
|
||||||
|
setHeaders: (res) => {
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
app.useStaticAssets(join(process.cwd(), 'public'), {
|
app.useStaticAssets(join(process.cwd(), 'public'), {
|
||||||
prefix: '/assets/',
|
prefix: '/assets/',
|
||||||
@@ -55,21 +65,23 @@ async function bootstrap() {
|
|||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
app.useGlobalInterceptors(new TransformInterceptor());
|
app.useGlobalInterceptors(new TransformInterceptor());
|
||||||
|
|
||||||
// Swagger
|
// Swagger is only exposed outside production to avoid leaking the
|
||||||
const config = new DocumentBuilder()
|
// full admin API surface.
|
||||||
.setTitle('InkReach Product Center API')
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
.setDescription('Backend API for InkReach Product Center')
|
const config = new DocumentBuilder()
|
||||||
.setVersion('1.0')
|
.setTitle('InkReach Product Center API')
|
||||||
.addBearerAuth()
|
.setDescription('Backend API for InkReach Product Center')
|
||||||
.build();
|
.setVersion('1.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
|
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
SwaggerModule.setup('api/docs', app, document);
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
}
|
||||||
|
|
||||||
const port = process.env.PORT ?? 3001;
|
const port = process.env.PORT ?? 3001;
|
||||||
await app.listen(port, '0.0.0.0');
|
await app.listen(port, '0.0.0.0');
|
||||||
console.log(`🚀 Application is running on: http://0.0.0.0:${port}`);
|
console.log(`🚀 Application is running on: http://0.0.0.0:${port}`);
|
||||||
console.log(`📚 Swagger documentation: http://0.0.0.0:${port}/api/docs`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make JSON.stringify aware of BigInt so outgoing responses containing
|
// Make JSON.stringify aware of BigInt so outgoing responses containing
|
||||||
|
|||||||
@@ -1,33 +1,45 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Post,
|
Post,
|
||||||
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { diskStorage } from 'multer';
|
import { diskStorage } from 'multer';
|
||||||
import { extname, join } from 'path';
|
import { extname, join } from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
|
||||||
const UPLOAD_DIR = join(process.cwd(), 'uploads');
|
const UPLOAD_DIR = join(process.cwd(), 'uploads');
|
||||||
|
|
||||||
|
// Explicit safe-image whitelist. SVG is deliberately excluded: it can
|
||||||
|
// carry scripts and is served from the same origin (stored XSS).
|
||||||
|
const ALLOWED_EXTENSIONS = /\.(png|jpe?g|webp|gif)$/i;
|
||||||
|
const ALLOWED_MIMETYPES = /^image\/(png|jpe?g|webp|gif)$/i;
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('upload')
|
@Controller('upload')
|
||||||
export class UploadController {
|
export class UploadController {
|
||||||
@Post('image')
|
@Post('image')
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor('file', {
|
FileInterceptor('file', {
|
||||||
storage: diskStorage({
|
storage: diskStorage({
|
||||||
destination: UPLOAD_DIR,
|
destination: UPLOAD_DIR,
|
||||||
filename: (_req, file, cb) => {
|
filename: (_req, file, cb) => {
|
||||||
const ext = extname(file.originalname) || '.png';
|
const ext = ALLOWED_EXTENSIONS.test(extname(file.originalname))
|
||||||
|
? extname(file.originalname).toLowerCase()
|
||||||
|
: '.png';
|
||||||
cb(null, `${randomUUID()}${ext}`);
|
cb(null, `${randomUUID()}${ext}`);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
limits: { fileSize: 5 * 1024 * 1024 },
|
limits: { fileSize: 5 * 1024 * 1024 },
|
||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
if (!file.mimetype.startsWith('image/')) {
|
if (!ALLOWED_EXTENSIONS.test(file.originalname) || !ALLOWED_MIMETYPES.test(file.mimetype)) {
|
||||||
return cb(new BadRequestException('仅支持图片文件'), false);
|
return cb(new BadRequestException('仅支持 png/jpg/webp/gif 图片'), false);
|
||||||
}
|
}
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: false },
|
||||||
|
sourcemap: { server: false, client: false },
|
||||||
|
|
||||||
modules: ['@nuxtjs/seo'],
|
modules: ['@nuxtjs/seo'],
|
||||||
|
|
||||||
|
|||||||
Generated
+25
@@ -99,6 +99,9 @@ importers:
|
|||||||
'@nestjs/swagger':
|
'@nestjs/swagger':
|
||||||
specifier: ^7.1.17
|
specifier: ^7.1.17
|
||||||
version: 7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
version: 7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||||
|
'@nestjs/throttler':
|
||||||
|
specifier: ^6.5.0
|
||||||
|
version: 6.5.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^5.8.0
|
specifier: ^5.8.0
|
||||||
version: 5.22.0(prisma@5.22.0)
|
version: 5.22.0(prisma@5.22.0)
|
||||||
@@ -120,6 +123,9 @@ importers:
|
|||||||
express:
|
express:
|
||||||
specifier: ^4.21.0
|
specifier: ^4.21.0
|
||||||
version: 4.22.1
|
version: 4.22.1
|
||||||
|
helmet:
|
||||||
|
specifier: ^8.3.0
|
||||||
|
version: 8.3.0
|
||||||
multer:
|
multer:
|
||||||
specifier: ^2.2.0
|
specifier: ^2.2.0
|
||||||
version: 2.2.0
|
version: 2.2.0
|
||||||
@@ -1401,6 +1407,13 @@ packages:
|
|||||||
'@nestjs/platform-express':
|
'@nestjs/platform-express':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@nestjs/throttler@6.5.0':
|
||||||
|
resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||||
|
'@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||||
|
reflect-metadata: ^0.1.13 || ^0.2.0
|
||||||
|
|
||||||
'@noble/hashes@1.8.0':
|
'@noble/hashes@1.8.0':
|
||||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||||
engines: {node: ^14.21.3 || >=16}
|
engines: {node: ^14.21.3 || >=16}
|
||||||
@@ -5488,6 +5501,10 @@ packages:
|
|||||||
hast-util-whitespace@3.0.0:
|
hast-util-whitespace@3.0.0:
|
||||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||||
|
|
||||||
|
helmet@8.3.0:
|
||||||
|
resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
hey-listen@1.0.8:
|
hey-listen@1.0.8:
|
||||||
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
|
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
|
||||||
|
|
||||||
@@ -9907,6 +9924,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
|
'@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
|
||||||
|
|
||||||
|
'@nestjs/throttler@6.5.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
reflect-metadata: 0.2.2
|
||||||
|
|
||||||
'@noble/hashes@1.8.0': {}
|
'@noble/hashes@1.8.0': {}
|
||||||
|
|
||||||
'@nodable/entities@2.2.0': {}
|
'@nodable/entities@2.2.0': {}
|
||||||
@@ -14247,6 +14270,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.5
|
'@types/hast': 3.0.5
|
||||||
|
|
||||||
|
helmet@8.3.0: {}
|
||||||
|
|
||||||
hey-listen@1.0.8: {}
|
hey-listen@1.0.8: {}
|
||||||
|
|
||||||
hookable@5.5.3: {}
|
hookable@5.5.3: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user