From e4948d955429af1c9d52167d5b53b122791d5ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tor=20Matz=20Andr=C3=A9n?= Date: Thu, 14 May 2026 16:08:54 +0200 Subject: [PATCH] Add basic hardening for auth and download paths --- src/backend/.env.default | 6 ++- src/backend/.env.docker | 6 ++- src/backend/src/app.module.ts | 9 +++- src/backend/src/environmentEnum.ts | 2 + src/backend/src/main.ts | 43 +++++++++------- src/backend/src/playlist/playlist.service.ts | 2 +- src/backend/src/shared/auth.guard.ts | 50 +++++++++++++++++++ src/backend/src/shared/utils.service.ts | 21 ++++++-- src/backend/src/track/track.service.ts | 32 ++++++------ src/frontend/src/app/app.config.ts | 15 ++++-- .../app/services/auth-token.interceptor.ts | 19 +++++++ 11 files changed, 158 insertions(+), 47 deletions(-) create mode 100644 src/backend/src/shared/auth.guard.ts create mode 100644 src/frontend/src/app/services/auth-token.interceptor.ts diff --git a/src/backend/.env.default b/src/backend/.env.default index 1aa8fb9..bb3fbac 100644 --- a/src/backend/.env.default +++ b/src/backend/.env.default @@ -14,4 +14,8 @@ SPOTIFY_CLIENT_SECRET=your_client_secret YT_COOKIES= YT_COOKIES_FILE=./config/cookies.txt -YT_DOWNLOADS_PER_MINUTE=3 \ No newline at end of file +YT_DOWNLOADS_PER_MINUTE=3 + +# Optional local hardening. Set AUTH_ENABLED=true and choose a strong token. +AUTH_ENABLED=false +SPOOTY_AUTH_TOKEN= diff --git a/src/backend/.env.docker b/src/backend/.env.docker index 74f06ae..39e0da7 100644 --- a/src/backend/.env.docker +++ b/src/backend/.env.docker @@ -12,4 +12,8 @@ SPOTIFY_CLIENT_ID=your_client_id SPOTIFY_CLIENT_SECRET=your_client_secret YT_COOKIES= -YT_DOWNLOADS_PER_MINUTE=3 \ No newline at end of file +YT_DOWNLOADS_PER_MINUTE=3 + +# Optional local hardening. Set AUTH_ENABLED=true and choose a strong token. +AUTH_ENABLED=false +SPOOTY_AUTH_TOKEN= diff --git a/src/backend/src/app.module.ts b/src/backend/src/app.module.ts index 166e489..ed33f2b 100644 --- a/src/backend/src/app.module.ts +++ b/src/backend/src/app.module.ts @@ -11,6 +11,8 @@ import { PlaylistEntity } from './playlist/playlist.entity'; import { resolve } from 'path'; import { EnvironmentEnum } from './environmentEnum'; import { BullModule } from '@nestjs/bullmq'; +import { APP_GUARD } from '@nestjs/core'; +import { AuthGuard } from './shared/auth.guard'; @Module({ imports: [ @@ -59,6 +61,11 @@ import { BullModule } from '@nestjs/bullmq'; PlaylistModule, ], controllers: [AppController], - providers: [], + providers: [ + { + provide: APP_GUARD, + useClass: AuthGuard, + }, + ], }) export class AppModule {} diff --git a/src/backend/src/environmentEnum.ts b/src/backend/src/environmentEnum.ts index 627bc6c..fb2d9b3 100644 --- a/src/backend/src/environmentEnum.ts +++ b/src/backend/src/environmentEnum.ts @@ -8,4 +8,6 @@ export enum EnvironmentEnum { REDIS_RUN = 'REDIS_RUN', YT_COOKIES = 'YT_COOKIES', YT_COOKIES_FILE = 'YT_COOKIES_FILE', + AUTH_ENABLED = 'AUTH_ENABLED', + SPOOTY_AUTH_TOKEN = 'SPOOTY_AUTH_TOKEN', } diff --git a/src/backend/src/main.ts b/src/backend/src/main.ts index e58ca65..26e9c41 100644 --- a/src/backend/src/main.ts +++ b/src/backend/src/main.ts @@ -5,27 +5,34 @@ import { resolve } from 'path'; import { exec } from 'child_process'; import { EnvironmentEnum } from './environmentEnum'; +function envFlag(name: string): boolean { + return String(process.env[name] || '').toLowerCase() === 'true'; +} + async function bootstrap() { + if (!process.env[EnvironmentEnum.DOWNLOADS_PATH]) { + throw new Error('DOWNLOADS_PATH environment variable is missing'); + } + + const folderName = resolve( + __dirname, + process.env[EnvironmentEnum.DOWNLOADS_PATH], + ); + fs.mkdirSync(folderName, { recursive: true }); + + if (envFlag(EnvironmentEnum.REDIS_RUN)) { + try { + // Convenience mode for the single-container Docker image. + // Hardened deployments should prefer an external Redis service. + exec(`redis-server --port ${process.env.REDIS_PORT || 6379}`); + } catch (e) { + console.log('Unable to run redis server from app'); + console.log(e); + } + } + const app = await NestFactory.create(AppModule); app.setGlobalPrefix('api'); await app.listen(process.env.PORT || 3000); } bootstrap(); - -if (!process.env[EnvironmentEnum.DOWNLOADS_PATH]) { - throw new Error('DOWNLOADS_PATH environment variable is missing'); -} -const folderName = resolve( - __dirname, - process.env[EnvironmentEnum.DOWNLOADS_PATH], -); -!fs.existsSync(folderName) && fs.mkdirSync(folderName); - -try { - // not good idea, but I want to keep simple Dockerfile, I know ideally should be in another container and used docker compose - Boolean(process.env[EnvironmentEnum.REDIS_RUN]) && - exec(`redis-server --port ${process.env.REDIS_PORT}`); -} catch (e) { - console.log('Unable to run redis server form app'); - console.log(e); -} diff --git a/src/backend/src/playlist/playlist.service.ts b/src/backend/src/playlist/playlist.service.ts index 5a612b7..8239df2 100644 --- a/src/backend/src/playlist/playlist.service.ts +++ b/src/backend/src/playlist/playlist.service.ts @@ -207,7 +207,7 @@ export class PlaylistService { private createPlaylistFolderStructure(playlistName: string): void { const playlistPath = this.utilsService.getPlaylistFolderPath(playlistName); - !fs.existsSync(playlistPath) && fs.mkdirSync(playlistPath); + !fs.existsSync(playlistPath) && fs.mkdirSync(playlistPath, { recursive: true }); } @Interval(3_600_000) diff --git a/src/backend/src/shared/auth.guard.ts b/src/backend/src/shared/auth.guard.ts new file mode 100644 index 0000000..09a1ddf --- /dev/null +++ b/src/backend/src/shared/auth.guard.ts @@ -0,0 +1,50 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import type { Request } from 'express'; + +@Injectable() +export class AuthGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + if (!this.authEnabled()) { + return true; + } + + const expectedToken = process.env.SPOOTY_AUTH_TOKEN; + if (!expectedToken) { + throw new UnauthorizedException( + 'AUTH_ENABLED=true but SPOOTY_AUTH_TOKEN is missing', + ); + } + + const request = context.switchToHttp().getRequest(); + const providedToken = this.extractToken(request); + + if (providedToken !== expectedToken) { + throw new UnauthorizedException('Invalid or missing Spooty auth token'); + } + + return true; + } + + private authEnabled(): boolean { + return String(process.env.AUTH_ENABLED || '').toLowerCase() === 'true'; + } + + private extractToken(request: Request): string | undefined { + const headerToken = request.header('x-spooty-token'); + if (headerToken) { + return headerToken; + } + + const authorization = request.header('authorization'); + if (authorization?.toLowerCase().startsWith('bearer ')) { + return authorization.slice('bearer '.length).trim(); + } + + return undefined; + } +} diff --git a/src/backend/src/shared/utils.service.ts b/src/backend/src/shared/utils.service.ts index e4993fb..73ea1cc 100644 --- a/src/backend/src/shared/utils.service.ts +++ b/src/backend/src/shared/utils.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { resolve } from 'path'; +import { relative, resolve } from 'path'; import { EnvironmentEnum } from '../environmentEnum'; @Injectable() @@ -16,13 +16,24 @@ export class UtilsService { } getPlaylistFolderPath(name: string): string { - return resolve( - this.getRootDownloadsPath(), - this.stripFileIllegalChars(name), + return this.ensureInsideDownloadsRoot( + resolve(this.getRootDownloadsPath(), this.stripFileIllegalChars(name)), ); } + ensureInsideDownloadsRoot(candidatePath: string): string { + const root = this.getRootDownloadsPath(); + const resolvedCandidate = resolve(candidatePath); + const rel = relative(root, resolvedCandidate); + + if (rel === '' || (!rel.startsWith('..') && !resolve(rel).startsWith('/'))) { + return resolvedCandidate; + } + + throw new Error(`Unsafe path outside downloads root: ${resolvedCandidate}`); + } + stripFileIllegalChars(text: string): string { - return text.replace(/[/\\?%*:|"<>]/g, '-'); + return text.replace(/[/\\?%*:|"<>]/g, '-').trim() || 'untitled'; } } diff --git a/src/backend/src/track/track.service.ts b/src/backend/src/track/track.service.ts index d5141a2..8a9b57f 100644 --- a/src/backend/src/track/track.service.ts +++ b/src/backend/src/track/track.service.ts @@ -110,27 +110,25 @@ export class TrackService { if (!(await this.get(track.id))) { return; } - if ( - !track.name || - !track.artist || - !track.playlist - ) { + if (!track.name || !track.artist || !track.playlist) { this.logger.error( `Track or playlist field is null or undefined: name=${track.name}, artist=${track.artist}, playlist=${track.playlist ? 'ok' : 'null'}`, ); return; } - // Use track's own coverUrl if available, otherwise fall back to playlist coverUrl + const coverUrl = track.coverUrl || track.playlist.coverUrl; if (!coverUrl) { this.logger.warn( `No cover art available for track: ${track.artist} - ${track.name}`, ); } + await this.update(track.id, { ...track, status: TrackStatusEnum.Downloading, }); + let error: string; try { const folderName = this.getFolderName(track, track.playlist); @@ -147,6 +145,7 @@ export class TrackService { this.logger.error(err); error = String(err); } + const updatedTrack = { ...track, status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed, @@ -157,24 +156,27 @@ export class TrackService { getTrackFileName(track: TrackEntity): string { const safeArtist = track.artist || 'unknown_artist'; - const safeName = (track.name || 'unknown_track').replace('/', ''); + const safeName = track.name || 'unknown_track'; const fileName = `${safeArtist} - ${safeName}`; return `${this.utilsService.stripFileIllegalChars(fileName)}.${this.configService.get(EnvironmentEnum.FORMAT)}`; } getFolderName(track: TrackEntity, playlist: PlaylistEntity): string { - // Individual tracks (isTrack=true) go in root downloads folder, playlists in subfolders if (playlist?.isTrack) { - return resolve( - this.utilsService.getRootDownloadsPath(), - this.getTrackFileName(track), + return this.utilsService.ensureInsideDownloadsRoot( + resolve( + this.utilsService.getRootDownloadsPath(), + this.getTrackFileName(track), + ), ); } - + const safePlaylistName = playlist?.name || 'unknown_playlist'; - return resolve( - this.utilsService.getPlaylistFolderPath(safePlaylistName), - this.getTrackFileName(track), + return this.utilsService.ensureInsideDownloadsRoot( + resolve( + this.utilsService.getPlaylistFolderPath(safePlaylistName), + this.getTrackFileName(track), + ), ); } } diff --git a/src/frontend/src/app/app.config.ts b/src/frontend/src/app/app.config.ts index 73ea233..2547396 100644 --- a/src/frontend/src/app/app.config.ts +++ b/src/frontend/src/app/app.config.ts @@ -1,15 +1,20 @@ -import {ApplicationConfig, importProvidersFrom, provideZoneChangeDetection} from '@angular/core'; +import { + ApplicationConfig, + importProvidersFrom, + provideZoneChangeDetection, +} from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; -import {provideHttpClient} from "@angular/common/http"; -import {SocketIoModule} from "ngx-socket-io"; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { SocketIoModule } from 'ngx-socket-io'; +import { authTokenInterceptor } from './services/auth-token.interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), - provideHttpClient(), - importProvidersFrom(SocketIoModule.forRoot({url: ''})) + provideHttpClient(withInterceptors([authTokenInterceptor])), + importProvidersFrom(SocketIoModule.forRoot({ url: '' })), ], }; diff --git a/src/frontend/src/app/services/auth-token.interceptor.ts b/src/frontend/src/app/services/auth-token.interceptor.ts new file mode 100644 index 0000000..cd9356f --- /dev/null +++ b/src/frontend/src/app/services/auth-token.interceptor.ts @@ -0,0 +1,19 @@ +import { HttpInterceptorFn } from '@angular/common/http'; + +const TOKEN_STORAGE_KEY = 'spooty_auth_token'; + +export const authTokenInterceptor: HttpInterceptorFn = (req, next) => { + const token = localStorage.getItem(TOKEN_STORAGE_KEY); + + if (!token) { + return next(req); + } + + return next( + req.clone({ + setHeaders: { + 'x-spooty-token': token, + }, + }), + ); +};