From 7ca2b458cab53b297ad476053ef49f3f1bc8f31d Mon Sep 17 00:00:00 2001 From: Larkin Young Date: Fri, 13 Feb 2026 17:28:27 +0000 Subject: [PATCH 1/2] Security hardening: path guard, type safety, CI audit, gitignore - Add path traversal guard to syncFile: reject paths that resolve outside process.cwd() (defense-in-depth) - Fix {} as LinkTransformContext: provide properly initialized default context using file's own sourcePath/targetPath instead of empty object - Add npm audit --audit-level=high to CI pipeline - Add .env and .env.* to .gitignore to prevent accidental credential commits - Add test for path traversal rejection in syncFile --- .github/workflows/ci.yml | 1 + .gitignore | 4 +++ .../src/github.link-transform.ts | 15 ++++++---- .../src/github.storage.spec.ts | 28 +++++++++++++------ .../astro-github-loader/src/github.storage.ts | 14 ++++++++-- 5 files changed, 44 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 359a570..d56f4f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ jobs: node-version: 22 cache: npm - run: npm ci + - run: npm audit --audit-level=high - run: npm run lint --workspace @larkiny/astro-github-loader - run: npm test --workspace @larkiny/astro-github-loader - run: cd packages/astro-github-loader && npx tsc --noEmit diff --git a/.gitignore b/.gitignore index c0ae90a..0353c0b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ node_modules/ *.tgz +# environment variables +.env +.env.* + # OS generated files .DS_Store .DS_Store? diff --git a/packages/astro-github-loader/src/github.link-transform.ts b/packages/astro-github-loader/src/github.link-transform.ts index fcf4ce9..9c70df9 100644 --- a/packages/astro-github-loader/src/github.link-transform.ts +++ b/packages/astro-github-loader/src/github.link-transform.ts @@ -189,6 +189,13 @@ function applyLinkMappings( let matched = false; let replacement = ""; + const getLinkTransformContext = (): LinkTransformContext => + context.currentFile.linkContext ?? { + sourcePath: context.currentFile.sourcePath, + targetPath: context.currentFile.targetPath, + basePath: "", + }; + if (typeof mapping.pattern === "string") { // String pattern - exact match or contains if (transformedPath.includes(mapping.pattern)) { @@ -199,12 +206,10 @@ function applyLinkMappings( mapping.replacement, ); } else { - const linkTransformContext = - context.currentFile.linkContext ?? ({} as LinkTransformContext); replacement = mapping.replacement( transformedPath, anchor, - linkTransformContext, + getLinkTransformContext(), ); } } @@ -219,12 +224,10 @@ function applyLinkMappings( mapping.replacement, ); } else { - const linkTransformContext = - context.currentFile.linkContext ?? ({} as LinkTransformContext); replacement = mapping.replacement( transformedPath, anchor, - linkTransformContext, + getLinkTransformContext(), ); } } diff --git a/packages/astro-github-loader/src/github.storage.spec.ts b/packages/astro-github-loader/src/github.storage.spec.ts index 53e0d72..9a12736 100644 --- a/packages/astro-github-loader/src/github.storage.spec.ts +++ b/packages/astro-github-loader/src/github.storage.spec.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, it, expect, vi } from "vitest"; +import { resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { syncFile, storeProcessedFile } from "./github.storage.js"; import { createMockContext } from "./test-helpers.js"; @@ -40,12 +41,14 @@ describe("syncFile", () => { it("creates directory and writes file when directory does not exist", async () => { await syncFile("some/nested/dir/file.md", "content"); - expect(mockedExistsSync).toHaveBeenCalledWith("some/nested/dir"); - expect(mockedMkdir).toHaveBeenCalledWith("some/nested/dir", { + const resolved = resolve("some/nested/dir/file.md"); + const resolvedDir = resolved.substring(0, resolved.lastIndexOf("/")); + expect(mockedExistsSync).toHaveBeenCalledWith(resolvedDir); + expect(mockedMkdir).toHaveBeenCalledWith(resolvedDir, { recursive: true, }); expect(mockedWriteFile).toHaveBeenCalledWith( - "some/nested/dir/file.md", + resolved, "content", "utf-8", ); @@ -56,10 +59,12 @@ describe("syncFile", () => { await syncFile("existing/dir/file.md", "content"); - expect(mockedExistsSync).toHaveBeenCalledWith("existing/dir"); + const resolved = resolve("existing/dir/file.md"); + const resolvedDir = resolved.substring(0, resolved.lastIndexOf("/")); + expect(mockedExistsSync).toHaveBeenCalledWith(resolvedDir); expect(mockedMkdir).not.toHaveBeenCalled(); expect(mockedWriteFile).toHaveBeenCalledWith( - "existing/dir/file.md", + resolved, "content", "utf-8", ); @@ -68,10 +73,9 @@ describe("syncFile", () => { it("skips mkdir when path has no directory component", async () => { await syncFile("file.md", "content"); - // dir is "" which is falsy, so existsSync should not be called for dir check - expect(mockedMkdir).not.toHaveBeenCalled(); + // resolved path still has a directory (cwd), but it exists expect(mockedWriteFile).toHaveBeenCalledWith( - "file.md", + resolve("file.md"), "content", "utf-8", ); @@ -82,11 +86,17 @@ describe("syncFile", () => { await syncFile("output/test.md", longContent); expect(mockedWriteFile).toHaveBeenCalledWith( - "output/test.md", + resolve("output/test.md"), longContent, "utf-8", ); }); + + it("rejects paths that escape project root", async () => { + await expect( + syncFile("../../etc/passwd", "malicious"), + ).rejects.toThrow("resolves outside project root"); + }); }); describe("storeProcessedFile", () => { diff --git a/packages/astro-github-loader/src/github.storage.ts b/packages/astro-github-loader/src/github.storage.ts index a89cf1f..315ec5f 100644 --- a/packages/astro-github-loader/src/github.storage.ts +++ b/packages/astro-github-loader/src/github.storage.ts @@ -1,18 +1,26 @@ import { existsSync, promises as fs } from "node:fs"; +import { resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import type { ImportedFile } from "./github.link-transform.js"; import type { ExtendedLoaderContext } from "./github.types.js"; /** * Ensures directory exists and writes file to disk. + * Validates that the resolved path stays within the project root. * @internal */ -export async function syncFile(path: string, content: string) { - const dir = path.substring(0, path.lastIndexOf("/")); +export async function syncFile(filePath: string, content: string) { + const resolved = resolve(filePath); + if (!resolved.startsWith(process.cwd())) { + throw new Error( + `syncFile: path "${filePath}" resolves outside project root`, + ); + } + const dir = resolved.substring(0, resolved.lastIndexOf("/")); if (dir && !existsSync(dir)) { await fs.mkdir(dir, { recursive: true }); } - await fs.writeFile(path, content, "utf-8"); + await fs.writeFile(resolved, content, "utf-8"); } /** From ff7f4588bfb358bb757841cf0297ac01a4d6dd9a Mon Sep 17 00:00:00 2001 From: Larkin Young Date: Sat, 14 Feb 2026 10:08:20 +0000 Subject: [PATCH 2/2] bumped version --- packages/astro-github-loader/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/astro-github-loader/package.json b/packages/astro-github-loader/package.json index 36bf7b7..3daf762 100644 --- a/packages/astro-github-loader/package.json +++ b/packages/astro-github-loader/package.json @@ -1,7 +1,7 @@ { "name": "@larkiny/astro-github-loader", "type": "module", - "version": "0.11.3", + "version": "0.12.0", "description": "Load content from GitHub repositories into Astro content collections with asset management and content transformations", "keywords": [ "astro",