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
+2 -4
View File
@@ -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
+17
View File
@@ -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
+2
View File
@@ -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",
+16
View File
@@ -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 {}
+7 -10
View File
@@ -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' })
+6 -1
View File
@@ -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',
},
}; };
}, },
}), }),
+56 -53
View File
@@ -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);
}); });
}); });
+20 -13
View File
@@ -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,
+18 -4
View File
@@ -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
View File
@@ -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
+15 -3
View File
@@ -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);
}, },
+2 -1
View File
@@ -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'],
+25
View File
@@ -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: {}