Skip to content

FileLoaderTask.server reads an arbitrary filesystem path with no root, no realpath and no entitlement #823

Description

@sroussey

What

packages/tasks/src/task/FileLoaderTask.server.ts resolves a local path the same way FileGrepTask.server did before #821's follow-up hardening:

if (url.startsWith("http://") || url.startsWith("https://")) {   // :30 — case-SENSITIVE
  return super.execute(input, context);
}
if (url.startsWith("file://")) {
  url = url.slice(7);                                            // :38-40 — no percent-decode, no host check
}
...
const fileContent = await readFile(url, { encoding: "utf-8" });  // :51 — unsandboxed

So:

  • no containment — no root, no allowlist, no path.resolve + prefix check, no realpath, and a relative path resolves against process.cwd();
  • no entitlement — the class declares no entitlements(), so Task.entitlements() returns EMPTY_ENTITLEMENTS even though Entitlements.FILESYSTEM_READ exists (TaskEntitlements.ts:77). A graph containing it passes an enforcer that grants no filesystem access;
  • file:// is sliced, not parsed — a percent-encoded name (a%20b.txt) addresses the wrong path, and file://evil.example/etc/passwd is silently read as evil.example/etc/passwd;
  • the scheme test is case-sensitiveHTTP://x falls through to the filesystem branch.

The task is registered globally by node.ts and electron.ts, so any workflow that can set a task input can read any file the process can.

Why it was not fixed in the FileGrep PR

It owns its own format detection / image / pdf surface and its own test file, and widening that diff hurts review. The helper the grep fix introduced — resolveLocalFilePath(url, { roots }) and isHttpUrl(url) in packages/tasks/src/util/LocalFilePath.server.ts — is deliberately task-agnostic, so the fix here is roughly:

if (isHttpUrl(url)) return super.execute(input, context);
const file = resolveLocalFilePath(url, { roots: this.config.roots });

plus a roots config entry, a configSchema() declaring it, and a filesystem:read entitlements() declaration scoped to the resolved path (mirroring FileGrepTask.server).

Acceptance

  • file:// URLs parsed via fileURLToPath, remote hosts rejected
  • path realpath'd, then checked against opt-in roots (check AFTER realpath, so a symlink cannot escape)
  • non-regular files refused
  • filesystem:read declared, scoped to the resolved path
  • isHttpUrl used for the scheme test
  • tests mirroring FileGrepTask.server.test.ts

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions