Add basic hardening for auth and download paths
This commit is contained in:
@@ -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=
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user