fix: close playlist path traversal, harden auth (startup validation, token expiry), and improve error handling

AuthorKonata <konata@posteo.jp>
Date
Commit7385039b7056687fc3f188451335fa6d23e54308
Parent48f6a38
10 files changed, 110 insertions(+), 70 deletions(-)
▾MREADME.md
@@ -38,15 +38,15 @@ If you choose to not run this via the include Containerfile, you need to ensure
- `mediainfo`: For probing media file metadata
## Configuration
You can configure the backend with the following environment variables:
You can configure the backend with the following environment variables:
- `COVER_REGEX`: JS-compatible regex to match filenames in a directory to find a matching cover art. Searches the directory structure upwards. Set to an empty string to disable cover detection.
- `EXCLUDE_EXTENSION`: comma-separated list of of file extensions to ignore completely, e.g. `txt,log,nfo`. If unset, uses a safe set of text files common in downloaded music archives. Useful because some files can get mis-scanned, e.g. some CD-scan .log files get scanned as mp1
- `EXCLUDE_EXTENSION`: comma-separated list of file extensions to ignore completely, e.g. `txt,log,nfo`. If unset, uses a safe set of text files common in downloaded music archives. Useful because some files can get mis-scanned, e.g. some CD-scan .log files get scanned as mp1
- `SCAN_CONCURRENCY`: maximum number of parallel `mediainfo` processes while scanning uncached folders. Defaults to `8`; invalid or non-positive values fall back to the default.
- `AUTH`: Same format as the string used for basic auth (username and password joined by a colon `:` and encoded to base64). If set, the server APIs return 401 unless the users is signed in with the given login details. Basic assets are not protected, so the UI itself will load fine even when not logged in, so when you get signed out for whatever reason (e.g. by the server restarting), you can still access the UI and play already synced files without problems.
- `AUTH`: Same format as the string used for basic auth (username and password joined by a colon `:` and encoded to base64). If set, the server APIs return 401 unless the user is signed in with the given login details. Basic assets are not protected, so the UI itself will load fine even when not logged in, so when you get signed out for whatever reason (e.g. by the server restarting), you can still access the UI and play already synced files without problems.
## Non-Goals
- **Metadata-Based Views**: No support for filtering by genre, artist, etc. Use other projects for this
- **Multiple users**: Right now everything besides the actual file hosting is stored on the server. When syncing is implemented, it will only be single storage. Permissison systems are also not planned. If distinct permissions/syncs are absouletely required, hosting multiple instances is an option
- **Multiple users**: Right now everything besides the actual file hosting is stored on the server. When syncing is implemented, it will only be single storage. Permission systems are also not planned. If distinct permissions/syncs are absolutely required, hosting multiple instances is an option
## FAQ
- What do the "Streaming mode" options do?
▾Mserver/src/index.ts
@@ -18,6 +18,7 @@ import {
authTokens,
fileTypeCache,
generatedPlaylistIds,
isValidAuthToken,
mediaTypes,
musicRoot,
type PathInfo,
@@ -52,6 +53,14 @@ function resolveInRoot(encodedPath: string): string | ServerError {
return filePath;
}
//for paths that arrive already decoded (playlist entries), where resolveInRoot's decodePath would
//double-decode - a legitimate "%" in a filename would throw in decodeURIComponent
function resolveRelativeInRoot(relPath: string): string | ServerError {
const filePath = path.join(musicRoot, relPath);
if (!isBelow(musicRoot, filePath)) return new ServerError(StatusMap.Forbidden, "Path outside the music root");
return filePath;
}
async function resolveMediaFile(encodedPath: string): Promise<{ filePath: string; info: PathInfo } | ServerError> {
const filePath = resolveInRoot(encodedPath);
if (filePath instanceof ServerError) return filePath;
@@ -141,7 +150,7 @@ const app = setup
({ cookie: { authToken } }) => {
const authRequired = !!(username && password);
//when no AUTH is configured every route is open, so treat the user as logged in
const loggedIn = !authRequired || (!!authToken.value && authTokens.has(authToken.value));
const loggedIn = !authRequired || (!!authToken.value && isValidAuthToken(authToken.value));
return { authRequired, loggedIn };
},
{ cookie: t.Cookie({ authToken: t.Optional(t.String()) }) },
@@ -160,7 +169,7 @@ const app = setup
{
cookie: t.Cookie({ authToken: t.Optional(t.String()) }),
beforeHandle({ cookie: { authToken }, set }) {
if (username && password && (!authToken.value || !authTokens.has(authToken.value))) {
if (username && password && (!authToken.value || !isValidAuthToken(authToken.value))) {
set.status = 401;
return "Unauthorized";
}
@@ -270,9 +279,8 @@ const app = setup
set.status = fileList.status;
return fileList.error;
}
set.headers["Content-Type"] = "application/json";
set.status = "OK";
return JSON.stringify(fileList);
return fileList;
},
{ query: t.Optional(t.Object({ recursive: t.Boolean() })) },
)
@@ -309,19 +317,23 @@ const app = setup
},
{ query: t.Optional(t.Object({ transcode: t.Boolean() })) },
)
.post("/prepare-playlist", async ({ set, body }) => {
set.status = "OK";
set.headers["Content-Type"] = "text/plain";
const id = randomUUIDv7();
generatedPlaylistIds.set(id, JSON.parse(body as string) as string[]);
setTimeout(
() => {
generatedPlaylistIds.delete(id);
},
1000 * 60 * 60, // 1 hour
);
return id;
})
.post(
"/prepare-playlist",
async ({ set, body }) => {
set.status = "OK";
set.headers["Content-Type"] = "text/plain";
const id = randomUUIDv7();
generatedPlaylistIds.set(id, body);
setTimeout(
() => {
generatedPlaylistIds.delete(id);
},
1000 * 60 * 60, // 1 hour
);
return id;
},
{ body: t.Array(t.String()) },
)
.get(
"/download-playlist/:id",
async ({ set, params }) => {
@@ -330,10 +342,20 @@ const app = setup
set.status = "Not Found";
return "Playlist ID not found";
}
const resolvedPaths: string[] = [];
for (const entry of playlist) {
const resolved = resolveRelativeInRoot(entry);
if (resolved instanceof ServerError) {
set.status = resolved.status;
return resolved.error;
}
resolvedPaths.push(resolved);
}
set.status = "OK";
if (playlist.length === 1) {
set.headers["Content-Disposition"] = `attachment; filename="${basename(playlist[0])}"`;
return new Response(Bun.file(path.join(musicRoot, playlist[0])));
if (resolvedPaths.length === 1) {
const safeName = basename(resolvedPaths[0]).replace(/["\\\r\n]/g, "_");
set.headers["Content-Disposition"] = `attachment; filename="${safeName}"`;
return new Response(Bun.file(resolvedPaths[0]));
}
set.headers["Content-Type"] = "application/x-tar";
set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`;
▾Mserver/src/shared.ts
@@ -15,12 +15,31 @@ if (musicRoot === "") {
process.exit(1);
}
export const [username, password] = process.env.AUTH
? Buffer.from(process.env.AUTH, "base64").toString().split(":", 2)
: [undefined, undefined];
export const [username, password] = (() => {
if (!process.env.AUTH) return [undefined, undefined];
const decoded = Buffer.from(process.env.AUTH, "base64").toString();
const separator = decoded.indexOf(":");
if (separator <= 0 || separator === decoded.length - 1) {
console.error("AUTH is set but invalid: expected base64 of 'username:password' with both parts non-empty");
process.exit(1);
}
return [decoded.slice(0, separator), decoded.slice(separator + 1)];
})();
export const authTokens = new Map<string, Date>();
//checks presence *and* expiry - the daily sweep below only bounds memory, it is not an
//enforcement mechanism, so a token must not stay valid past its end date while awaiting it
export function isValidAuthToken(token: string): boolean {
const endDate = authTokens.get(token);
if (!endDate) return false;
if (endDate.getTime() < Date.now()) {
authTokens.delete(token);
return false;
}
return true;
}
//clear outdated tokens once a day
setInterval(
() => {
▾Mserver/src/utils.ts
@@ -4,6 +4,7 @@ import path from "node:path";
import { StatusMap } from "elysia";
import { fileTypeFromBlob } from "file-type";
import { parseFile } from "music-metadata";
import { AsyncSemaphore } from "music-server-shared/semaphore";
import type { FileListingWithStatus, Metadata } from "music-server-shared/types";
import tmp from "tmp";
import {
@@ -48,30 +49,7 @@ interface MediaInfoResult {
} | null;
}
//bound the number of concurrent mediainfo processes while scanning uncached folders
class Semaphore {
private running = 0;
private waitQueue: (() => void)[] = [];
constructor(private maxSize: number) {}
async acquire(): Promise<void> {
if (this.running < this.maxSize) {
this.running++;
return;
}
//the released slot is handed over directly, so running stays unchanged
return new Promise((resolve) => this.waitQueue.push(resolve));
}
release(): void {
const next = this.waitQueue.shift();
if (next) next();
else this.running--;
}
}
const mediaInfoSemaphore = new Semaphore(scanConcurrency);
const mediaInfoSemaphore = new AsyncSemaphore(scanConcurrency);
export async function findCover(targetPath: string): Promise<CoverResult> {
try {
@@ -300,6 +278,7 @@ export async function listFiles(subPath: string, recursive: boolean): Promise<Fi
);
return files;
} catch (error) {
if (error instanceof ServerError) return error;
if (errorCode(error) === "ENOENT") {
return new ServerError(StatusMap["Not Found"], "Directory not found");
}
@@ -385,7 +364,8 @@ export async function toAvif(
}
},
});
await cmd.exited;
const exitCode = await cmd.exited;
if (exitCode !== 0) throw new Error(`avifenc failed with status code ${exitCode}`);
setContentType("image/avif");
return readFileSync(tmpFile2.fd) as Uint8Array<ArrayBuffer>;
} catch {
▾Mshared/package.json
@@ -1 +1,11 @@
{}
{
"name": "music-server-shared",
"version": "1.0.0",
"license": "AGPL-3.0-only",
"type": "module",
"exports": {
"./types": "./types.ts",
"./utils": "./utils.ts",
"./semaphore": "./semaphore.ts"
}
}
▾Rshared/semaphore.ts← webclient/src/semaphore.ts
@@ -1,5 +1,7 @@
type PromiseResolver = () => void;
//shared between the server (bounding parallel mediainfo processes) and the client (bounding parallel
//downloads), so the abort-aware behavior only has to be correct once
export class AsyncSemaphore {
private maxConcurrency: number;
private running: number;
▾Mshared/types.ts
@@ -32,7 +32,7 @@ export interface Directory {
//Scanned = we know which children exist
//Unknown = either:
// - not scanned (is a subdirectory and recursive=false)
// - only partially exists, because some deeper file was scannend, and this entry only exists as a link
// - only partially exists, because some deeper file was scanned, and this entry only exists as a link
export interface DirectoryWithStatus {
files: FileListingWithStatus;
status: "Scanned" | "Unknown";
▾Mwebclient/src/App.tsx
@@ -122,7 +122,7 @@ const App: Component = () => {
const [breadcrumbs, setBreadcrumbs] = createSignal<string[]>([]);
const [isShuffled, setShuffled] = createSignal(false);
const [isRepeated, setRepeated] = createSignal(false);
const [isOffline, setOffline] = createSignal(true);
const [isOffline, setOffline] = createSignal(false);
const [files, fetchFiles] = createResource<FlatFileListing, { dir: string; offline: boolean }>(
() => ({ dir: currentDir(), offline: isOffline() }),
async (args) => {
@@ -677,7 +677,7 @@ const App: Component = () => {
const savedRepeat = localStorage.getItem(LocalStorageValues.repeat);
setRepeated(savedRepeat === "true");
const savedOffline = localStorage.getItem(LocalStorageValues.offline);
setOffline(savedOffline === "true");
if (savedOffline !== null) setOffline(savedOffline === "true");
const savedPath = localStorage.getItem(LocalStorageValues.path);
if (options.rememberDir && savedPath !== null && !window.location.hash) {
setCurrentDir(savedPath);
▾Mwebclient/src/components/Playlist.tsx
@@ -62,6 +62,9 @@ export default function Playlist(props: PlaylistProps) {
body: JSON.stringify(filePaths),
method: "POST",
credentials: "same-origin",
//the server validates the body against a JSON array schema, which only applies when
//the content type says it is JSON (a bare string body arrives as text/plain)
headers: { "content-type": "application/json" },
});
if (handleUnauthorized(response)) return;
if (!response.ok) throw new Error(`Generating playlist id not successful: ${await response.text()}`);
▾Mwebclient/src/offline.ts
@@ -7,11 +7,11 @@ import type {
MediaFile,
Metadata,
} from "music-server-shared/types";
import { AsyncSemaphore } from "music-server-shared/semaphore";
import { audioCodecHasBitrate, encodePath } from "music-server-shared/utils";
import { batch, from, type ResourceActions, untrack } from "solid-js";
import { createStore } from "solid-js/store";
import type { FlatFileListing } from "./App";
import { AsyncSemaphore } from "./semaphore";
import { type AppOptions, type Playlist, StreamingMode } from "./types";
import { basename, dirname, handleUnauthorized, joinPath, parentPaths, toast, unproxy } from "./utils";
@@ -390,18 +390,22 @@ export async function getSrc(
const disableChunkedTranscoding = settings.streamingMode === StreamingMode.buffered;
const audioBitrate = audioCodecHasBitrate(settings.audioCodec) ? settings.audioBitrate : undefined;
const query = new URLSearchParams({
disableChunkedTranscoding: String(disableChunkedTranscoding),
container: settings.container,
audioCodec: settings.audioCodec,
languages: options.languages,
});
if (isVideo) {
query.set("videoCodec", options.videoSettings.videoCodec);
query.set("videoBitrate", options.videoSettings.videoBitrate.toString());
query.set("videoEncodingSetting", options.videoSettings.videoEncodingSetting);
}
if (audioBitrate !== undefined) query.set("audioBitrate", audioBitrate.toString());
if (seekTo !== 0) query.set("seekTo", seekTo.toString());
return {
src:
`${joinPath("transcode", encodePath(path))}` +
`?disableChunkedTranscoding=${disableChunkedTranscoding}` +
`&container=${settings.container}` +
`${isVideo ? `&videoCodec=${options.videoSettings.videoCodec}` : ""}` +
`${isVideo ? `&videoBitrate=${options.videoSettings.videoBitrate}` : ""}` +
`${isVideo ? `&videoEncodingSetting=${options.videoSettings.videoEncodingSetting}` : ""}` +
`&audioCodec=${settings.audioCodec}` +
`${audioBitrate === undefined ? "" : `&audioBitrate=${audioBitrate}`}` +
`${seekTo === 0 ? "" : `&seekTo=${seekTo}`}` +
`&languages=${options.languages}`,
src: `${joinPath("transcode", encodePath(path))}?${query}`,
isVideo: isVideo,
};
}
@@ -470,8 +474,8 @@ export async function deleteFile(
coversToDelete.add(fullPath);
for (const filePath of toDelete) {
for (const dir of parentPaths(filePath)) {
//stop at the deleted root, parents of it may still hold other synced files
if (dir.length <= fullPath.length) break;
//stop at the deleted root itself, parents of it may still hold other synced files
if (dir === fullPath) break;
coversToDelete.add(dir);
}
}