Add basic hardening for auth and download paths

This commit is contained in:
Tor Matz Andrén
2026-05-14 16:08:54 +02:00
parent 786eddef1c
commit e4948d9554
11 changed files with 158 additions and 47 deletions
+4
View File
@@ -15,3 +15,7 @@ SPOTIFY_CLIENT_SECRET=your_client_secret
YT_COOKIES= YT_COOKIES=
YT_COOKIES_FILE=./config/cookies.txt YT_COOKIES_FILE=./config/cookies.txt
YT_DOWNLOADS_PER_MINUTE=3 YT_DOWNLOADS_PER_MINUTE=3
# Optional local hardening. Set AUTH_ENABLED=true and choose a strong token.
AUTH_ENABLED=false
SPOOTY_AUTH_TOKEN=
+4
View File
@@ -13,3 +13,7 @@ SPOTIFY_CLIENT_SECRET=your_client_secret
YT_COOKIES= YT_COOKIES=
YT_DOWNLOADS_PER_MINUTE=3 YT_DOWNLOADS_PER_MINUTE=3
# Optional local hardening. Set AUTH_ENABLED=true and choose a strong token.
AUTH_ENABLED=false
SPOOTY_AUTH_TOKEN=
+8 -1
View File
@@ -11,6 +11,8 @@ import { PlaylistEntity } from './playlist/playlist.entity';
import { resolve } from 'path'; import { resolve } from 'path';
import { EnvironmentEnum } from './environmentEnum'; import { EnvironmentEnum } from './environmentEnum';
import { BullModule } from '@nestjs/bullmq'; import { BullModule } from '@nestjs/bullmq';
import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './shared/auth.guard';
@Module({ @Module({
imports: [ imports: [
@@ -59,6 +61,11 @@ import { BullModule } from '@nestjs/bullmq';
PlaylistModule, PlaylistModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [], providers: [
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
}) })
export class AppModule {} export class AppModule {}
+2
View File
@@ -8,4 +8,6 @@ export enum EnvironmentEnum {
REDIS_RUN = 'REDIS_RUN', REDIS_RUN = 'REDIS_RUN',
YT_COOKIES = 'YT_COOKIES', YT_COOKIES = 'YT_COOKIES',
YT_COOKIES_FILE = 'YT_COOKIES_FILE', YT_COOKIES_FILE = 'YT_COOKIES_FILE',
AUTH_ENABLED = 'AUTH_ENABLED',
SPOOTY_AUTH_TOKEN = 'SPOOTY_AUTH_TOKEN',
} }
+25 -18
View File
@@ -5,27 +5,34 @@ import { resolve } from 'path';
import { exec } from 'child_process'; import { exec } from 'child_process';
import { EnvironmentEnum } from './environmentEnum'; import { EnvironmentEnum } from './environmentEnum';
function envFlag(name: string): boolean {
return String(process.env[name] || '').toLowerCase() === 'true';
}
async function bootstrap() { 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); const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
await app.listen(process.env.PORT || 3000); await app.listen(process.env.PORT || 3000);
} }
bootstrap(); 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);
}
+1 -1
View File
@@ -207,7 +207,7 @@ export class PlaylistService {
private createPlaylistFolderStructure(playlistName: string): void { private createPlaylistFolderStructure(playlistName: string): void {
const playlistPath = this.utilsService.getPlaylistFolderPath(playlistName); const playlistPath = this.utilsService.getPlaylistFolderPath(playlistName);
!fs.existsSync(playlistPath) && fs.mkdirSync(playlistPath); !fs.existsSync(playlistPath) && fs.mkdirSync(playlistPath, { recursive: true });
} }
@Interval(3_600_000) @Interval(3_600_000)
+50
View File
@@ -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<Request>();
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;
}
}
+16 -5
View File
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { resolve } from 'path'; import { relative, resolve } from 'path';
import { EnvironmentEnum } from '../environmentEnum'; import { EnvironmentEnum } from '../environmentEnum';
@Injectable() @Injectable()
@@ -16,13 +16,24 @@ export class UtilsService {
} }
getPlaylistFolderPath(name: string): string { getPlaylistFolderPath(name: string): string {
return resolve( return this.ensureInsideDownloadsRoot(
this.getRootDownloadsPath(), resolve(this.getRootDownloadsPath(), this.stripFileIllegalChars(name)),
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 { stripFileIllegalChars(text: string): string {
return text.replace(/[/\\?%*:|"<>]/g, '-'); return text.replace(/[/\\?%*:|"<>]/g, '-').trim() || 'untitled';
} }
} }
+16 -14
View File
@@ -110,27 +110,25 @@ export class TrackService {
if (!(await this.get(track.id))) { if (!(await this.get(track.id))) {
return; return;
} }
if ( if (!track.name || !track.artist || !track.playlist) {
!track.name ||
!track.artist ||
!track.playlist
) {
this.logger.error( this.logger.error(
`Track or playlist field is null or undefined: name=${track.name}, artist=${track.artist}, playlist=${track.playlist ? 'ok' : 'null'}`, `Track or playlist field is null or undefined: name=${track.name}, artist=${track.artist}, playlist=${track.playlist ? 'ok' : 'null'}`,
); );
return; return;
} }
// Use track's own coverUrl if available, otherwise fall back to playlist coverUrl
const coverUrl = track.coverUrl || track.playlist.coverUrl; const coverUrl = track.coverUrl || track.playlist.coverUrl;
if (!coverUrl) { if (!coverUrl) {
this.logger.warn( this.logger.warn(
`No cover art available for track: ${track.artist} - ${track.name}`, `No cover art available for track: ${track.artist} - ${track.name}`,
); );
} }
await this.update(track.id, { await this.update(track.id, {
...track, ...track,
status: TrackStatusEnum.Downloading, status: TrackStatusEnum.Downloading,
}); });
let error: string; let error: string;
try { try {
const folderName = this.getFolderName(track, track.playlist); const folderName = this.getFolderName(track, track.playlist);
@@ -147,6 +145,7 @@ export class TrackService {
this.logger.error(err); this.logger.error(err);
error = String(err); error = String(err);
} }
const updatedTrack = { const updatedTrack = {
...track, ...track,
status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed, status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed,
@@ -157,24 +156,27 @@ export class TrackService {
getTrackFileName(track: TrackEntity): string { getTrackFileName(track: TrackEntity): string {
const safeArtist = track.artist || 'unknown_artist'; const safeArtist = track.artist || 'unknown_artist';
const safeName = (track.name || 'unknown_track').replace('/', ''); const safeName = track.name || 'unknown_track';
const fileName = `${safeArtist} - ${safeName}`; const fileName = `${safeArtist} - ${safeName}`;
return `${this.utilsService.stripFileIllegalChars(fileName)}.${this.configService.get<string>(EnvironmentEnum.FORMAT)}`; return `${this.utilsService.stripFileIllegalChars(fileName)}.${this.configService.get<string>(EnvironmentEnum.FORMAT)}`;
} }
getFolderName(track: TrackEntity, playlist: PlaylistEntity): string { getFolderName(track: TrackEntity, playlist: PlaylistEntity): string {
// Individual tracks (isTrack=true) go in root downloads folder, playlists in subfolders
if (playlist?.isTrack) { if (playlist?.isTrack) {
return resolve( return this.utilsService.ensureInsideDownloadsRoot(
this.utilsService.getRootDownloadsPath(), resolve(
this.getTrackFileName(track), this.utilsService.getRootDownloadsPath(),
this.getTrackFileName(track),
),
); );
} }
const safePlaylistName = playlist?.name || 'unknown_playlist'; const safePlaylistName = playlist?.name || 'unknown_playlist';
return resolve( return this.utilsService.ensureInsideDownloadsRoot(
this.utilsService.getPlaylistFolderPath(safePlaylistName), resolve(
this.getTrackFileName(track), this.utilsService.getPlaylistFolderPath(safePlaylistName),
this.getTrackFileName(track),
),
); );
} }
} }
+10 -5
View File
@@ -1,15 +1,20 @@
import {ApplicationConfig, importProvidersFrom, provideZoneChangeDetection} from '@angular/core'; import {
ApplicationConfig,
importProvidersFrom,
provideZoneChangeDetection,
} from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import {provideHttpClient} from "@angular/common/http"; import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {SocketIoModule} from "ngx-socket-io"; import { SocketIoModule } from 'ngx-socket-io';
import { authTokenInterceptor } from './services/auth-token.interceptor';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideZoneChangeDetection({ eventCoalescing: true }), provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes), provideRouter(routes),
provideHttpClient(), provideHttpClient(withInterceptors([authTokenInterceptor])),
importProvidersFrom(SocketIoModule.forRoot({url: ''})) importProvidersFrom(SocketIoModule.forRoot({ url: '' })),
], ],
}; };
@@ -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,
},
}),
);
};