diff --git a/src/listFiles.spec.ts b/src/listFiles.spec.ts index db7d3f2ec1e..8fae32d5817 100644 --- a/src/listFiles.spec.ts +++ b/src/listFiles.spec.ts @@ -1,4 +1,9 @@ import { expect } from "chai"; +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { rmSync } from "node:fs"; import { listFiles } from "./listFiles"; import { FIXTURE_DIR } from "./test/fixtures/ignores"; @@ -30,4 +35,84 @@ describe("listFiles", () => { "present/index.html", ]); }); + + describe("symlink handling", () => { + let tmpRoot = ""; + let outsideTarget = ""; + + before(() => { + tmpRoot = path.join(os.tmpdir(), "fb-tools-listFiles-" + crypto.randomBytes(6).toString("hex")); + fs.mkdirSync(tmpRoot, { recursive: true }); + // Two regular files inside the public dir. + fs.writeFileSync(path.join(tmpRoot, "index.html"), ""); + fs.mkdirSync(path.join(tmpRoot, "static")); + fs.writeFileSync(path.join(tmpRoot, "static", "app.js"), "// app"); + // A target outside the public dir to simulate the credential-leak case. + outsideTarget = path.join(os.tmpdir(), "fb-tools-listFiles-leak-" + crypto.randomBytes(6).toString("hex")); + fs.writeFileSync(outsideTarget, "SECRET-CONTENT"); + }); + + after(() => { + rmSync(tmpRoot, { recursive: true, force: true }); + rmSync(outsideTarget, { force: true }); + }); + + it("excludes a symlink-to-file at the top level (security: prevents leaking files outside source tree)", () => { + const linkPath = path.join(tmpRoot, "leak"); + try { + fs.symlinkSync(outsideTarget, linkPath); + const result = listFiles(tmpRoot); + expect(result.sort()).to.have.members(["index.html", "static/app.js"]); + expect(result).to.not.include("leak"); + } finally { + try { + fs.unlinkSync(linkPath); + } catch { + /* ignore */ + } + } + }); + + it("excludes a symlink-to-file nested inside the source tree", () => { + const linkPath = path.join(tmpRoot, "static", "leak"); + try { + fs.symlinkSync(outsideTarget, linkPath); + const result = listFiles(tmpRoot); + expect(result.sort()).to.have.members(["index.html", "static/app.js"]); + expect(result).to.not.include("static/leak"); + } finally { + try { + fs.unlinkSync(linkPath); + } catch { + /* ignore */ + } + } + }); + + it("does not descend into symlinked directories", () => { + const outsideDir = path.join( + os.tmpdir(), + "fb-tools-listFiles-leakdir-" + crypto.randomBytes(6).toString("hex"), + ); + fs.mkdirSync(outsideDir); + fs.writeFileSync(path.join(outsideDir, "secret"), "SECRET-CONTENT"); + const linkDirPath = path.join(tmpRoot, "linked-dir"); + try { + fs.symlinkSync(outsideDir, linkDirPath, "dir"); + const result = listFiles(tmpRoot); + expect(result.sort()).to.have.members(["index.html", "static/app.js"]); + // No file from the linked dir should appear, regardless of stat behavior. + for (const r of result) { + expect(r.startsWith("linked-dir")).to.equal(false); + } + } finally { + try { + fs.unlinkSync(linkDirPath); + } catch { + /* ignore */ + } + rmSync(outsideDir, { recursive: true, force: true }); + } + }); + }); }); diff --git a/src/listFiles.ts b/src/listFiles.ts index 731694f5302..6da9819647f 100644 --- a/src/listFiles.ts +++ b/src/listFiles.ts @@ -1,12 +1,58 @@ +import { lstatSync } from "fs"; +import { join } from "path"; import { sync } from "glob"; +import { logger } from "./logger"; + +/** + * Recursively list deployable files under `cwd`. + * + * **Security: symlinks are excluded by default.** + * + * Hosting deploys take this list, read each entry with `fs.readFile*` + * (which follows symlinks at the OS layer), and uploads the bytes to a + * Firebase Hosting site. If the source tree contains a symlink such as + * `public/leak -> /proc/self/environ` or + * `public/leak -> ~/.config/gcloud/application_default_credentials.json`, + * the target's contents would otherwise end up published on a + * Firebase-hosted public URL. + * + * The largest exposure is CI workflows that extract attacker-supplied + * tarballs into the public directory before invoking `firebase deploy` + * (e.g. PR-preview deploy actions). Both `glob({ follow: true })` and + * `glob({ follow: false })` would return symlink-to-file entries in + * the result (`follow` only controls whether symlinked *directories* + * are descended into); the explicit `lstatSync` filter below drops + * symlinks of either kind. + */ export function listFiles(cwd: string, ignore: string[] = []): string[] { - return sync("**/*", { + const matched = sync("**/*", { cwd, dot: true, - follow: true, + follow: false, ignore: ["**/firebase-debug.log", "**/firebase-debug.*.log", ".firebase/*"].concat(ignore), nodir: true, posix: true, }); + const out: string[] = []; + for (const rel of matched) { + let stats; + try { + // `lstat` does NOT follow symlinks, so we can detect them and skip. + stats = lstatSync(join(cwd, rel)); + } catch { + // Stat error: skip the entry rather than risk uploading something + // we can't classify. + continue; + } + if (stats.isSymbolicLink()) { + logger.debug( + `[hosting] dropping symlink \`${rel}\` from upload list ` + + `(security: prevents symlink-following from exposing files outside the source tree)`, + ); + continue; + } + out.push(rel); + } + return out; }