Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/firefox/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
} from 'node:fs';
import { connect as netConnect } from 'node:net';
import { homedir } from 'node:os';
import { join, delimiter } from 'node:path';
import { dirname, join, delimiter } from 'node:path';
import type { FirefoxLaunchOptions } from './types.js';
import { log, logDebug } from '../utils/logger.js';
import { resolveProfilePath } from './profile.js';
Expand Down Expand Up @@ -363,6 +363,10 @@ export class FirefoxCore {
}

if (this.logFilePath) {
// Create the parent directory, as the generated-path branch above does.
// Without it a caller-supplied path whose directory is missing throws
// ENOENT from deep inside connect().
mkdirSync(dirname(this.logFilePath), { recursive: true });
// Open file for appending, create if doesn't exist
this.logFileFd = openSync(this.logFilePath, 'a');
serviceBuilder.setStdio(['ignore', this.logFileFd, this.logFileFd]);
Expand Down
4 changes: 4 additions & 0 deletions src/utils/logger.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from 'node:fs';
import path from 'node:path';

let logStream: fs.WriteStream | null = null;

Expand Down Expand Up @@ -74,6 +75,9 @@ export function logError(message: string, error?: unknown): void {
}

export function setupLogFile(filePath: string): void {
// Create the parent directory first; otherwise a missing one only surfaces as
// a stream error and logging is silently disabled.
fs.mkdirSync(path.dirname(filePath), { recursive: true });
logStream = fs.createWriteStream(filePath, { flags: 'a' });
logStream.on('error', (error) => {
console.error(`[firefox-devtools-mcp] Error writing to log file: ${error.message}`);
Expand Down
16 changes: 15 additions & 1 deletion src/utils/save-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,20 @@ async function isDirectory(path: string): Promise<boolean> {
}
}

/**
* Whether `candidate` is `root` or sits inside it. Compared case-insensitively
* on Windows, where `c:\Users\...` and `C:\Users\...` are the same directory —
* matching case exactly would reject valid paths, not catch escapes.
*/
export function isWithinRoot(root: string, candidate: string): boolean {
const normalize = (path: string) => (process.platform === 'win32' ? path.toLowerCase() : path);
const normalizedRoot = normalize(root);
const normalizedCandidate = normalize(candidate);
return (
normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(normalizedRoot + sep)
);
}

/**
* Reject saveTo paths that escape the allowed roots, unless the server was
* started with --unrestricted-save-paths. Relative paths must stay within the
Expand All @@ -41,7 +55,7 @@ async function assertAllowedPath(saveTo: string, resolvedPath: string): Promise<
return;
}
const root = isAbsolute(saveTo) ? homeRoot() : process.cwd();
if (resolvedPath !== root && !resolvedPath.startsWith(root + sep)) {
if (!isWithinRoot(root, resolvedPath)) {
throw new Error(
`saveTo "${saveTo}" resolves outside the allowed location (${resolvedPath}). Relative ` +
`paths must stay within the current working directory and absolute paths within ` +
Expand Down
39 changes: 38 additions & 1 deletion tests/utils/save-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ vi.mock('node:os', async (importOriginal) => {
const mockArgs = vi.hoisted(() => ({ unrestrictedSavePaths: false }));
vi.mock('../../src/index.js', () => ({ args: mockArgs }));

import { saveOutput } from '../../src/utils/save-output.js';
import { saveOutput, isWithinRoot } from '../../src/utils/save-output.js';

describe('saveOutput', () => {
const tempDir = join(tmpdir(), `save-output-test-${process.pid}`);
Expand Down Expand Up @@ -135,3 +135,40 @@ describe('saveOutput', () => {
});
});
});

describe('isWithinRoot', () => {
const originalPlatform = process.platform;
const root = join('C:', 'Users', 'me', '.firefox-devtools-mcp');

const setPlatform = (platform: NodeJS.Platform) =>
Object.defineProperty(process, 'platform', { value: platform, configurable: true });

afterEach(() => setPlatform(originalPlatform));

it('accepts the root itself and paths inside it', () => {
expect(isWithinRoot(root, root)).toBe(true);
expect(isWithinRoot(root, join(root, 'out.json'))).toBe(true);
expect(isWithinRoot(root, join(root, 'nested', 'out.json'))).toBe(true);
});

it('rejects paths outside the root, including prefix look-alikes', () => {
expect(isWithinRoot(root, join('C:', 'Users', 'me', 'secrets', 'out.json'))).toBe(false);
expect(isWithinRoot(root, `${root}-evil`)).toBe(false);
});

it('ignores case on Windows, where the filesystem does too', () => {
setPlatform('win32');
expect(isWithinRoot(root, join(root.toLowerCase(), 'out.json'))).toBe(true);
expect(isWithinRoot(root, join(root.toUpperCase(), 'out.json'))).toBe(true);
});

it('still rejects an escape when case is ignored', () => {
setPlatform('win32');
expect(isWithinRoot(root, join('c:', 'users', 'me', 'secrets', 'out.json'))).toBe(false);
});

it('matches case exactly off Windows', () => {
setPlatform('linux');
expect(isWithinRoot(root, join(root.toLowerCase(), 'out.json'))).toBe(false);
});
});