mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
Backend/frontend hardening & consistency cleanups (#1113)
* refactor(auth): session token validation and password-change consistency * refactor(journey): entry field allow-list and public share-link consistency * refactor(mcp): align tool authorization with the REST permission checks * chore: input validation and sanitisation touch-ups (uploads, pdf, maps, backup, csp)
This commit is contained in:
@@ -9,13 +9,14 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import type { Request } from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -76,12 +77,15 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Put('me/password')
|
||||
changePassword(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) {
|
||||
changePassword(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
this.limit('login', req, 5);
|
||||
const result = this.auth.changePassword(user.id, user.email, body);
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
// Refresh this device's cookie with the new password_version so the user
|
||||
// stays logged in here while all other sessions are invalidated.
|
||||
if (result.token) this.auth.setAuthCookie(res, result.token, req);
|
||||
writeAudit({ userId: user.id, action: 'user.password_change', ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ export class BudgetController {
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const member = this.budget.toggleMemberPaid(id, userId, paid);
|
||||
const member = this.budget.toggleMemberPaid(id, tripId, userId, paid);
|
||||
this.budget.broadcast(tripId, 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, socketId);
|
||||
return { member };
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@ export class BudgetService {
|
||||
return svc.updateMembers(id, tripId, userIds);
|
||||
}
|
||||
|
||||
toggleMemberPaid(id: string, userId: string, paid: boolean) {
|
||||
return svc.toggleMemberPaid(id, userId, paid);
|
||||
toggleMemberPaid(id: string, tripId: string, userId: string, paid: boolean) {
|
||||
return svc.toggleMemberPaid(id, tripId, userId, paid);
|
||||
}
|
||||
|
||||
setPayers(id: string, tripId: string, payers: { user_id: number; amount: number }[]) {
|
||||
|
||||
@@ -52,9 +52,11 @@ export class JourneyPublicController {
|
||||
const wantThumb = kind === 'thumbnail' ? 'thumbnail' : 'original';
|
||||
|
||||
if (provider === 'local') {
|
||||
const resolved = path.resolve(path.join(__dirname, '../../../uploads/journey', assetId));
|
||||
const uploadsDir = path.resolve(__dirname, '../../../uploads');
|
||||
if (!resolved.startsWith(uploadsDir) || !fs.existsSync(resolved)) {
|
||||
// Local journey assets are flat filenames; use basename() and confine the
|
||||
// resolved path to the journey upload directory.
|
||||
const journeyDir = path.resolve(__dirname, '../../../uploads/journey');
|
||||
const resolved = path.resolve(path.join(journeyDir, path.basename(assetId)));
|
||||
if (!resolved.startsWith(journeyDir + path.sep) || !fs.existsSync(resolved)) {
|
||||
throw new HttpException({ error: 'Not found' }, 404);
|
||||
}
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Controller, Get, Query, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { OidcService } from './oidc.service';
|
||||
import { cookieOptions } from '../../services/cookie';
|
||||
|
||||
const OIDC_STATE_COOKIE = 'trek_oidc_state';
|
||||
|
||||
/**
|
||||
* /api/auth/oidc — OIDC SSO login flow (Authorization Code + PKCE).
|
||||
@@ -40,6 +43,11 @@ export class OidcController {
|
||||
const redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`;
|
||||
const inviteToken = req.query.invite as string | undefined;
|
||||
const { state, codeChallenge } = this.oidc.createState(redirectUri, inviteToken);
|
||||
// Bind the state to THIS browser. The callback requires a matching cookie,
|
||||
// so an attacker-initiated login (whose callback URL carries a valid state
|
||||
// from the shared server map) cannot be replayed in a victim's browser to
|
||||
// log them into the attacker's account (OIDC login CSRF / session fixation).
|
||||
res.cookie(OIDC_STATE_COOKIE, state, { ...cookieOptions(false, req), maxAge: 10 * 60 * 1000 });
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: config.clientId,
|
||||
@@ -61,10 +69,15 @@ export class OidcController {
|
||||
@Query('code') code: string | undefined,
|
||||
@Query('state') state: string | undefined,
|
||||
@Query('error') oidcError: string | undefined,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const f = (p: string) => res.redirect(this.oidc.frontendUrl(p));
|
||||
|
||||
// The state cookie is single-use — clear it regardless of the outcome.
|
||||
const boundState = (req.cookies as Record<string, string> | undefined)?.[OIDC_STATE_COOKIE];
|
||||
res.clearCookie(OIDC_STATE_COOKIE, cookieOptions(true, req));
|
||||
|
||||
if (!this.oidc.oidcLoginEnabled()) return f('/login?oidc_error=sso_disabled');
|
||||
if (oidcError) {
|
||||
console.error('[OIDC] Provider error:', oidcError);
|
||||
@@ -72,6 +85,9 @@ export class OidcController {
|
||||
}
|
||||
if (!code || !state) return f('/login?oidc_error=missing_params');
|
||||
|
||||
// Require the callback to come from the browser that started the flow.
|
||||
if (!boundState || boundState !== state) return f('/login?oidc_error=invalid_state');
|
||||
|
||||
const pending = this.oidc.consumeState(state);
|
||||
if (!pending) return f('/login?oidc_error=invalid_state');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user