mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
fix: harden CLI against path traversal, env injection, and cleartext registry fetches
- **`app.dir` and region key path traversal.** `appSpecSchema` now validates `dir` with `safeRelativePath` at parse time (rejecting `..`, absolute paths, and null bytes); the `regions` record key receives the same treatment. `applyModule` and `cmdRemove` re-check every resolved write/delete destination with the new `assertWithinRoot` helper, which walks the full symlink chain so a planted link can't redirect file operations outside the project root even if the lexical check passes. - **Env var line injection in `.env.example`.** `envVarSchema` now validates `name` against `^[A-Za-z_][A-Za-z0-9_]*$` and rejects control characters in `example` and `description`; the `appendEnvVar` helper applies the same check as defense-in-depth so a crafted third-party module can't smuggle extra `KEY=value` lines into the generated file. - **Cleartext registry/npm endpoint rejection.** Remote `http://` URLs for `STANZA_REGISTRY`, `registries[*].url`, and `STANZA_NPM_REGISTRY` are now refused (`STANZA_REGISTRY`/`STANZA_NPM_REGISTRY` hard-error; third-party `http://` entries warn and skip so the rest of the CLI keeps working). `file://`, bare filesystem paths, and loopback hosts stay allowed for local-dev and CI-fixture workflows. Set `STANZA_ALLOW_INSECURE_REGISTRY=1` to opt into a trusted internal `http://` mirror with a one-time stderr warning.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@withstanza/schema": patch
|
||||
"stanza-cli": patch
|
||||
---
|
||||
|
||||
Guard `app.dir` against path traversal and symlink escape. The manifest's `apps[].dir` is joined onto the project root for every write into an app, but was previously an unvalidated `z.string()` — a crafted/attacker-authored `stanza.json` (cloning an untrusted repo, CI automation) could set `dir: "../../etc"` and land template files outside the project. `appSpecSchema` now validates `dir` with `safeRelativePath` at parse time (rejecting `..`, absolute paths, and null bytes, so both `add` and `remove` refuse the manifest on read), and `applyModule` re-checks each target app's `dir` and asserts every resolved write destination stays within the project root after symlink resolution — closing a symlinked-app-dir escape that a lexical check alone misses.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@withstanza/schema": patch
|
||||
"stanza-cli": patch
|
||||
---
|
||||
|
||||
Reject env var line-injection in `.env.example` generation. Module env declarations now validate `name` against the dotenv/shell key pattern `^[A-Za-z_][A-Za-z0-9_]*$` and reject control characters (newlines, CR, …) in `example` and `description`, both at the schema boundary (`envVarSchema`, applied to fetched third-party modules) and as a defense-in-depth guard inside the pure `appendEnvVar` helper. Previously a module with a newline in `name`/`example`/`description` could smuggle extra `KEY=value` lines into the generated `.env.example`.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@withstanza/schema": patch
|
||||
"stanza-cli": patch
|
||||
---
|
||||
|
||||
Guard `regions` file keys against path traversal and symlink escape. `stanza remove` deletes files using the region keys read from `stanza.json`, but those outer keys were previously an unvalidated `z.string()` — a crafted/attacker-authored manifest (cloning an untrusted repo, CI automation) could set a region key to `"../../etc/evil"` and make `remove` `unlinkSync` a path outside the project. The `regions` record key is now validated with `safeRelativePath` at parse time (rejecting `..`, absolute paths, and null bytes, so `remove` refuses the manifest on read), and the remove command re-checks each region key and asserts the resolved real path stays within the project root after symlink resolution before any delete sink — closing a symlinked-directory escape that a lexical check alone misses (sibling to the `app.dir` guard).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"stanza-cli": minor
|
||||
---
|
||||
|
||||
Refuse cleartext `http://` for remote registry and npm endpoints. Registry and npm payloads have no integrity check beyond TLS, so a cleartext endpoint — set via `STANZA_REGISTRY`, a third-party `stanza.json#registries[*].url`, or `STANZA_NPM_REGISTRY` — let an on-path attacker swap module content or steer dependency versions, reaching code execution when the user installs/builds the vendored output. The loader now rejects remote `http://` and requires `https://`. `file://` URLs, bare filesystem paths, and loopback hosts (`localhost`/`127.0.0.1`/`::1`) stay allowed untouched, so local-dev, air-gapped, and CI-fixture workflows (including a local npm proxy) are unaffected. A third-party `http://` registry is skipped with a warning while the rest of the CLI keeps working; an `http://` value for `STANZA_REGISTRY`/`STANZA_NPM_REGISTRY` is a hard error. Set the new `STANZA_ALLOW_INSECURE_REGISTRY=1` to opt into a trusted internal `http://` mirror — the CLI prints a one-time stderr warning when it does.
|
||||
@@ -5,7 +5,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { CATEGORIES } from "@withstanza/schema";
|
||||
import { CATEGORIES, CURRENT_MANIFEST_VERSION } from "@withstanza/schema";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../..");
|
||||
@@ -267,6 +267,49 @@ describe("cmdRemove", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdRemove path-traversal hardening", () => {
|
||||
it("refuses to delete through a symlinked region path that escapes the root", async () => {
|
||||
const projectRoot = path.join(tmp, "proj");
|
||||
fs.mkdirSync(path.join(projectRoot, "apps"), { recursive: true });
|
||||
|
||||
// A sentinel that lives OUTSIDE the project root.
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-outside-"));
|
||||
const sentinel = path.join(outside, "evil.ts");
|
||||
fs.writeFileSync(sentinel, "// attacker-controlled\n");
|
||||
// `apps/web` is a symlink at the outside dir, so the lexically-valid region
|
||||
// key `apps/web/evil.ts` resolves to the sentinel only once the link is
|
||||
// followed — the schema can't catch this, but the delete sink must.
|
||||
fs.symlinkSync(outside, path.join(projectRoot, "apps", "web"));
|
||||
|
||||
const manifest = {
|
||||
version: CURRENT_MANIFEST_VERSION,
|
||||
projectShape: "monorepo",
|
||||
packageManager: "pnpm",
|
||||
name: "proj",
|
||||
// `ghost` isn't in the registry, so revert is skipped and we fall through
|
||||
// to the declarative delete loop where the symlink guard fires.
|
||||
modules: {
|
||||
framework: [{ id: "ghost", version: "0.0.0", adapter: "default", apps: ["web"] }],
|
||||
},
|
||||
apps: [{ id: "web", dir: "apps/web", kind: "web" }],
|
||||
regions: { "apps/web/evil.ts": { file: "ghost@web" } },
|
||||
};
|
||||
fs.writeFileSync(path.join(projectRoot, "stanza.json"), JSON.stringify(manifest, null, 2));
|
||||
|
||||
try {
|
||||
process.chdir(projectRoot);
|
||||
await expect(cmdRemove(args({ slot: "framework" }))).rejects.toThrow(
|
||||
/escapes the project root/,
|
||||
);
|
||||
// The file outside the root must be untouched.
|
||||
expect(fs.existsSync(sentinel)).toBe(true);
|
||||
} finally {
|
||||
process.chdir(tmp);
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("add-ons (multi-choice testing slot)", () => {
|
||||
it("init --yes installs two add-ons in one category without a region conflict", async () => {
|
||||
await cmdInit(
|
||||
|
||||
@@ -16,10 +16,11 @@ import {
|
||||
parseModuleSpec,
|
||||
selectedAll,
|
||||
} from "@withstanza/schema";
|
||||
import { assertSafeRelativePath } from "@withstanza/utils";
|
||||
import { defineCommand } from "citty";
|
||||
import pc from "picocolors";
|
||||
|
||||
import { revertCodemods } from "../lib/codemod-runner";
|
||||
import { assertWithinRoot, revertCodemods } from "../lib/codemod-runner";
|
||||
import { ensureCleanWorktree } from "../lib/git";
|
||||
import { findProjectRoot, readManifest, writeManifest } from "../lib/manifest";
|
||||
import { regenerateReadmeIfUnmodified } from "../lib/readme";
|
||||
@@ -195,6 +196,12 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
|
||||
: [installed.id];
|
||||
const owned = regionsOwnedBy(manifest, ownerKeys);
|
||||
for (const { file, region } of owned) {
|
||||
// Defense in depth — the schema rejects traversal region keys at parse, but
|
||||
// re-check before every delete sink for in-memory/stale manifests and
|
||||
// resolve symlinks so a planted link can't redirect the unlink outside the
|
||||
// project root.
|
||||
assertSafeRelativePath(file, "region file key");
|
||||
assertWithinRoot(projectRoot, file, "region file key");
|
||||
const abs = path.join(projectRoot, file);
|
||||
if (file === ".env.example") {
|
||||
if (!dryRun) removeEnvVar(abs, region);
|
||||
@@ -268,6 +275,10 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
|
||||
if (stillUsed) continue;
|
||||
const pkgRoot = path.join(projectRoot, "packages", dir);
|
||||
if (!fs.existsSync(pkgRoot)) continue;
|
||||
// `dir` is a trusted constant (from PACKAGE_DIRS), but the sweep recurses
|
||||
// and deletes — assert the slot dir resolves inside the root so a planted
|
||||
// `packages/<dir>` symlink can't redirect the scan/rm outside it.
|
||||
assertWithinRoot(projectRoot, path.join("packages", dir), "package slot dir");
|
||||
const consumers = protectors.get(dir);
|
||||
if (consumers && consumers.length > 0) {
|
||||
manualCleanup.push(
|
||||
|
||||
@@ -5,7 +5,12 @@ import path from "node:path";
|
||||
import { defineModule, type Module, emptyManifest } from "@withstanza/schema";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { planSlotPackageBootstrap, recordFor, writeDepKeepingHigher } from "./codemod-runner";
|
||||
import {
|
||||
assertWithinRoot,
|
||||
planSlotPackageBootstrap,
|
||||
recordFor,
|
||||
writeDepKeepingHigher,
|
||||
} from "./codemod-runner";
|
||||
|
||||
let tmp: string;
|
||||
|
||||
@@ -214,6 +219,97 @@ describe("planSlotPackageBootstrap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertWithinRoot", () => {
|
||||
it("accepts a normal relative target, existing or not", () => {
|
||||
fs.mkdirSync(path.join(tmp, "apps", "web"), { recursive: true });
|
||||
expect(() => assertWithinRoot(tmp, "apps/web", "app dir")).not.toThrow();
|
||||
// A not-yet-created tail under a contained parent is fine.
|
||||
expect(() => assertWithinRoot(tmp, "apps/web/src/foo.ts", "template dest")).not.toThrow();
|
||||
// The root itself is contained.
|
||||
expect(() => assertWithinRoot(tmp, ".", "root")).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects a `..` traversal segment", () => {
|
||||
expect(() => assertWithinRoot(tmp, "../../etc", "app dir")).toThrow(/escapes the project root/);
|
||||
expect(() => assertWithinRoot(tmp, "a/../../b", "app dir")).toThrow(/escapes the project root/);
|
||||
});
|
||||
|
||||
it("rejects a symlinked app dir pointing outside the root", () => {
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-outside-"));
|
||||
try {
|
||||
fs.mkdirSync(path.join(tmp, "apps"), { recursive: true });
|
||||
fs.symlinkSync(outside, path.join(tmp, "apps", "web"));
|
||||
expect(() => assertWithinRoot(tmp, "apps/web", "app dir")).toThrow(
|
||||
/escapes the project root/,
|
||||
);
|
||||
// ...and any write that would land through it.
|
||||
expect(() => assertWithinRoot(tmp, "apps/web/src/foo.ts", "template dest")).toThrow(
|
||||
/escapes the project root/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a dangling symlink pointing outside the root", () => {
|
||||
fs.mkdirSync(path.join(tmp, "apps"), { recursive: true });
|
||||
// Target doesn't exist — `mkdirSync -p` would otherwise create it outside.
|
||||
fs.symlinkSync(path.join(os.tmpdir(), "stanza-ghost-target"), path.join(tmp, "apps", "web"));
|
||||
expect(() => assertWithinRoot(tmp, "apps/web/src/foo.ts", "template dest")).toThrow(
|
||||
/escapes the project root/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a two-hop symlink chain that escapes (both hops exist)", () => {
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-outside-"));
|
||||
try {
|
||||
fs.mkdirSync(path.join(tmp, "apps"), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmp, "real"), { recursive: true });
|
||||
// apps/web -> real/web (lexically in-root) -> outside (escapes). A guard
|
||||
// that only checks the first hop's target would miss this.
|
||||
fs.symlinkSync(outside, path.join(tmp, "real", "web"));
|
||||
fs.symlinkSync(path.join(tmp, "real", "web"), path.join(tmp, "apps", "web"));
|
||||
expect(() => assertWithinRoot(tmp, "apps/web/src/foo.ts", "template dest")).toThrow(
|
||||
/escapes the project root/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a chain whose escape hides in a link target's own path", () => {
|
||||
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-outside-"));
|
||||
try {
|
||||
fs.mkdirSync(path.join(tmp, "apps"), { recursive: true });
|
||||
// real -> outside, then apps/web -> real/web. apps/web's immediate target
|
||||
// (`real/web`) is lexically in-root, but `real` itself redirects outside.
|
||||
// Leave `outside/web` missing so apps/web is a dangling link.
|
||||
fs.symlinkSync(outside, path.join(tmp, "real"));
|
||||
fs.symlinkSync(path.join(tmp, "real", "web"), path.join(tmp, "apps", "web"));
|
||||
expect(() => assertWithinRoot(tmp, "apps/web/src/foo.ts", "region file key")).toThrow(
|
||||
/escapes the project root/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a symlink that stays inside the root", () => {
|
||||
fs.mkdirSync(path.join(tmp, "real", "web"), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmp, "apps"), { recursive: true });
|
||||
fs.symlinkSync(path.join(tmp, "real", "web"), path.join(tmp, "apps", "web"));
|
||||
expect(() => assertWithinRoot(tmp, "apps/web/src/foo.ts", "template dest")).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts a dangling in-root symlink (tail not yet created)", () => {
|
||||
fs.mkdirSync(path.join(tmp, "real"), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmp, "apps"), { recursive: true });
|
||||
// apps/web -> real/web, which doesn't exist yet but stays inside the root.
|
||||
fs.symlinkSync(path.join(tmp, "real", "web"), path.join(tmp, "apps", "web"));
|
||||
expect(() => assertWithinRoot(tmp, "apps/web/src/foo.ts", "template dest")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordFor", () => {
|
||||
const baseAdapter = { key: "default", match: {} };
|
||||
const seedApp = { id: "web", dir: "apps/web", kind: "web" as const };
|
||||
|
||||
@@ -102,6 +102,16 @@ export async function applyModule(args: {
|
||||
);
|
||||
}
|
||||
|
||||
// `app.dir` is manifest-supplied and is joined onto projectRoot for every
|
||||
// write below (templates, package.json, codemod project roots). Zod guards it
|
||||
// at parse, but re-check here for in-memory/stale manifests and resolve
|
||||
// symlinks so a planted symlinked app dir can't redirect writes outside the
|
||||
// project.
|
||||
for (const app of targetApps) {
|
||||
assertSafeRelativePath(app.dir, `app "${app.id}" dir`);
|
||||
assertWithinRoot(projectRoot, app.dir, `app "${app.id}" dir`);
|
||||
}
|
||||
|
||||
// `home: app` records can coexist across apps for multi-cardinality
|
||||
// categories (e.g. testing). Composite owner disambiguates so a remove on
|
||||
// one app doesn't sweep another app's claims. Non-app homes stay on bare
|
||||
@@ -184,6 +194,7 @@ export async function applyModule(args: {
|
||||
const dest = path.join(projectRoot, app.dir, tpl.dest);
|
||||
// Forward-slash so the manifest stays portable across Windows + Unix.
|
||||
const rel = path.relative(projectRoot, dest).replaceAll(path.sep, "/");
|
||||
assertWithinRoot(projectRoot, rel, `${module.id} template dest`);
|
||||
manifest = claim(manifest, rel, "file", owner);
|
||||
if (!dryRun) {
|
||||
const source = readTemplateSource(tpl);
|
||||
@@ -205,6 +216,7 @@ export async function applyModule(args: {
|
||||
category: module.category,
|
||||
});
|
||||
const rel = path.relative(projectRoot, dest).replaceAll(path.sep, "/");
|
||||
assertWithinRoot(projectRoot, rel, `${module.id} template dest`);
|
||||
manifest = claim(manifest, rel, "file", owner);
|
||||
if (!dryRun) {
|
||||
const source = readTemplateSource(tpl);
|
||||
@@ -882,3 +894,80 @@ function shouldKeepExisting(existing: string, incoming: string): boolean {
|
||||
function isSemverishRange(v: string): boolean {
|
||||
return semver.validRange(v) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully resolve symlinks in `p`, tolerating a non-existent tail. Like
|
||||
* `fs.realpathSync`, but it doesn't throw when the path (or a dangling link
|
||||
* along it) is missing — we need to vet not-yet-created write destinations.
|
||||
*
|
||||
* It finds the deepest ancestor that exists *as a node* (using `lstat`, so a
|
||||
* dangling symlink still counts — `existsSync` would follow it and wrongly
|
||||
* treat it as absent), canonicalizes that ancestor, and appends the remaining
|
||||
* tail verbatim. The tail holds no symlinks because those components don't
|
||||
* exist. A dangling symlink is resolved through its own target so a link
|
||||
* *inside the target's path* can't hide an escape. Symlink cycles surface as
|
||||
* `ELOOP` from `realpathSync` and propagate — refusing the op is the safe
|
||||
* outcome.
|
||||
*/
|
||||
function realpathAllowingMissing(p: string, depth = 0): string {
|
||||
if (depth > 40) throw new Error(`too many symbolic links resolving ${JSON.stringify(p)}`);
|
||||
let existing = path.resolve(p);
|
||||
const tail: string[] = [];
|
||||
for (;;) {
|
||||
if (fs.lstatSync(existing, { throwIfNoEntry: false })) break;
|
||||
const parent = path.dirname(existing);
|
||||
if (parent === existing) return path.resolve(p); // nothing along the path exists
|
||||
tail.unshift(path.basename(existing));
|
||||
existing = parent;
|
||||
}
|
||||
let realBase: string;
|
||||
try {
|
||||
realBase = fs.realpathSync(existing);
|
||||
} catch (err) {
|
||||
// A dangling symlink: `realpathSync` can't follow it. Resolve its target's
|
||||
// existing portion ourselves. Any non-ENOENT error (e.g. an `ELOOP` cycle)
|
||||
// propagates, which safely aborts the operation.
|
||||
const isMissing =
|
||||
typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
||||
if (!isMissing) throw err;
|
||||
const linkTarget = path.resolve(path.dirname(existing), fs.readlinkSync(existing));
|
||||
realBase = realpathAllowingMissing(linkTarget, depth + 1);
|
||||
}
|
||||
return tail.length > 0 ? path.join(realBase, ...tail) : realBase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a project-relative write/delete target stays inside `projectRoot`
|
||||
* after symlink resolution. `assertSafeRelativePath` rejects `..`/absolute
|
||||
* inputs lexically; this adds the filesystem leg — a symlinked directory along
|
||||
* the path (even a dangling one, which `mkdirSync -p` would follow) can still
|
||||
* redirect the operation outside the root. The target need not exist yet: its
|
||||
* symlink-free tail is resolved verbatim. Resolution is full (it follows
|
||||
* symlinks nested inside a link's own target, and dangling links), so a
|
||||
* multi-hop chain can't smuggle an escape past a single-hop check.
|
||||
*/
|
||||
export function assertWithinRoot(projectRoot: string, relativePath: string, label: string): void {
|
||||
const root = path.resolve(projectRoot);
|
||||
const realRoot = fs.realpathSync(root);
|
||||
const segments = relativePath.split(/[/\\]+/).filter((s) => s !== "" && s !== ".");
|
||||
// Reject `..` lexically before any join — a traversal segment must never be
|
||||
// resolved against the root, and isn't something symlink resolution vets.
|
||||
if (segments.includes("..")) {
|
||||
throw new Error(
|
||||
`${label}: path escapes the project root (got ${JSON.stringify(relativePath)})`,
|
||||
);
|
||||
}
|
||||
const resolved = realpathAllowingMissing(path.join(realRoot, ...segments));
|
||||
// The resolved path may be spelled against the real root or an aliased
|
||||
// ancestor (e.g. macOS `/var` -> `/private/var`); it's an escape only when it
|
||||
// falls outside *both* spellings of the root.
|
||||
const under = (base: string): boolean => {
|
||||
const r = path.relative(base, resolved);
|
||||
return r === "" || (r !== ".." && !r.startsWith(`..${path.sep}`) && !path.isAbsolute(r));
|
||||
};
|
||||
if (!under(realRoot) && !under(root)) {
|
||||
throw new Error(
|
||||
`${label}: ${JSON.stringify(relativePath)} escapes the project root (resolves to ${JSON.stringify(resolved)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
resolveRange,
|
||||
resolveRanges,
|
||||
} from "./npm-version";
|
||||
import { clearInsecureWarningsForTests } from "./secure-url";
|
||||
|
||||
const VERSIONS = ["1.6.11", "1.6.20", "1.7.0", "1.8.3", "2.0.0"];
|
||||
|
||||
@@ -24,12 +25,18 @@ function mockFetch(versions: string[] = VERSIONS) {
|
||||
|
||||
beforeEach(() => {
|
||||
clearVersionCacheForTests();
|
||||
clearInsecureWarningsForTests();
|
||||
delete process.env.STANZA_NO_NPM_LOOKUP;
|
||||
delete process.env.STANZA_NPM_REGISTRY;
|
||||
delete process.env.STANZA_ALLOW_INSECURE_REGISTRY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.STANZA_NO_NPM_LOOKUP;
|
||||
delete process.env.STANZA_NPM_REGISTRY;
|
||||
delete process.env.STANZA_ALLOW_INSECURE_REGISTRY;
|
||||
});
|
||||
|
||||
describe("resolveRange", () => {
|
||||
@@ -142,3 +149,30 @@ describe("resolveRanges", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("STANZA_NPM_REGISTRY scheme enforcement", () => {
|
||||
it("rejects a remote http:// npm registry without the opt-in (no fetch)", async () => {
|
||||
process.env.STANZA_NPM_REGISTRY = "http://npm.example";
|
||||
const fetchMock = mockFetch();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(resolveRange("better-auth", "^1.6.11")).rejects.toThrow(/must use https:\/\//);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a remote http:// npm registry with the opt-in and warns", async () => {
|
||||
process.env.STANZA_NPM_REGISTRY = "http://npm.example";
|
||||
process.env.STANZA_ALLOW_INSECURE_REGISTRY = "1";
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
vi.stubGlobal("fetch", mockFetch());
|
||||
expect(await resolveRange("better-auth", "^1.6.11")).toBe("^1.8.3");
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("cleartext http://"));
|
||||
});
|
||||
|
||||
it("accepts a loopback http:// npm registry without the opt-in", async () => {
|
||||
process.env.STANZA_NPM_REGISTRY = "http://127.0.0.1:4873";
|
||||
const fetchMock = mockFetch();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
expect(await resolveRange("better-auth", "^1.6.11")).toBe("^1.8.3");
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import semver from "semver";
|
||||
|
||||
const NPM_REGISTRY = process.env.STANZA_NPM_REGISTRY ?? "https://registry.npmjs.org";
|
||||
import { assertSecureFetchUrl } from "./secure-url";
|
||||
|
||||
/** Read lazily so the scheme guard sees the configured value at lookup time. */
|
||||
function npmRegistry(): string {
|
||||
return process.env.STANZA_NPM_REGISTRY ?? "https://registry.npmjs.org";
|
||||
}
|
||||
|
||||
// pkg name -> available versions, or null when a prior lookup failed. Lives for
|
||||
// the CLI process so a multi-module `init` hits npm at most once per package.
|
||||
@@ -27,8 +32,12 @@ function encodeName(name: string): string {
|
||||
async function fetchVersions(name: string): Promise<string[] | null> {
|
||||
const cached = cache.get(name);
|
||||
if (cached !== undefined) return cached;
|
||||
const registry = npmRegistry();
|
||||
// Reject cleartext http:// before the try below, which would otherwise turn a
|
||||
// security rejection into a silent verbatim-range fallback.
|
||||
assertSecureFetchUrl(registry, "npm registry URL");
|
||||
try {
|
||||
const res = await fetch(`${NPM_REGISTRY}/${encodeName(name)}`, {
|
||||
const res = await fetch(`${registry}/${encodeName(name)}`, {
|
||||
headers: { accept: "application/vnd.npm.install-v1+json" },
|
||||
// Bound the slow path (offline, captive portal) so init/add doesn't hang.
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { emptyManifest } from "@withstanza/schema";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
import { loadRegistries } from "./registry-loader";
|
||||
import { clearInsecureWarningsForTests } from "./secure-url";
|
||||
|
||||
const INDEX = { generatedAt: "t", schemaVersion: 2, categories: [], modules: [] };
|
||||
|
||||
/** Stub `fetch` so any http(s) registry main file resolves to an empty index. */
|
||||
function okFetch() {
|
||||
return vi.fn<() => Promise<{ ok: true; text: () => Promise<string> }>>(async () => ({
|
||||
ok: true,
|
||||
text: async () => JSON.stringify(INDEX),
|
||||
}));
|
||||
}
|
||||
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-loader-"));
|
||||
clearInsecureWarningsForTests();
|
||||
delete process.env.STANZA_REGISTRY;
|
||||
delete process.env.STANZA_ALLOW_INSECURE_REGISTRY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
delete process.env.STANZA_REGISTRY;
|
||||
delete process.env.STANZA_ALLOW_INSECURE_REGISTRY;
|
||||
});
|
||||
|
||||
/** Write the empty index to a temp file and return its path. */
|
||||
function writeIndexFile(): string {
|
||||
const file = path.join(tmp, "index.json");
|
||||
fs.writeFileSync(file, JSON.stringify(INDEX));
|
||||
return file;
|
||||
}
|
||||
|
||||
describe("loadRegistries scheme enforcement", () => {
|
||||
it("rejects a remote http:// STANZA_REGISTRY without the opt-in", async () => {
|
||||
process.env.STANZA_REGISTRY = "http://mirror.example/registry/index.json";
|
||||
const fetchMock = okFetch();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(loadRegistries()).rejects.toThrow(/must use https:\/\//);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a remote http:// STANZA_REGISTRY with the opt-in and warns", async () => {
|
||||
process.env.STANZA_REGISTRY = "http://mirror.example/registry/index.json";
|
||||
process.env.STANZA_ALLOW_INSECURE_REGISTRY = "1";
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
vi.stubGlobal("fetch", okFetch());
|
||||
const regs = await loadRegistries();
|
||||
expect(regs.namespaces()).toEqual(["@stanza"]);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("cleartext http://"));
|
||||
});
|
||||
|
||||
it("accepts an https:// STANZA_REGISTRY", async () => {
|
||||
process.env.STANZA_REGISTRY = "https://mirror.example/registry/index.json";
|
||||
vi.stubGlobal("fetch", okFetch());
|
||||
const regs = await loadRegistries();
|
||||
expect(regs.namespaces()).toEqual(["@stanza"]);
|
||||
});
|
||||
|
||||
it("accepts a file:// STANZA_REGISTRY", async () => {
|
||||
process.env.STANZA_REGISTRY = pathToFileURL(writeIndexFile()).toString();
|
||||
const regs = await loadRegistries();
|
||||
expect(regs.namespaces()).toEqual(["@stanza"]);
|
||||
});
|
||||
|
||||
it("accepts a bare filesystem path STANZA_REGISTRY", async () => {
|
||||
process.env.STANZA_REGISTRY = writeIndexFile();
|
||||
const regs = await loadRegistries();
|
||||
expect(regs.namespaces()).toEqual(["@stanza"]);
|
||||
});
|
||||
|
||||
it("skips a third-party http:// registry but keeps @stanza working", async () => {
|
||||
process.env.STANZA_REGISTRY = writeIndexFile();
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const manifest = emptyManifest({ name: "acme" });
|
||||
manifest.registries = { "@evil": "http://evil.example/index.json" };
|
||||
const regs = await loadRegistries(manifest);
|
||||
expect(regs.namespaces()).toContain("@stanza");
|
||||
expect(regs.namespaces()).not.toContain("@evil");
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
RegistryIndexSchema,
|
||||
} from "@withstanza/schema";
|
||||
|
||||
import { assertSecureFetchUrl } from "./secure-url";
|
||||
|
||||
/**
|
||||
* The published Stanza website hosts the canonical first-party registry. A
|
||||
* registry is addressed by the full URL to its **main JSON file** (the index);
|
||||
@@ -128,6 +130,9 @@ function buildCustomLoader(cfg: RegistryConfig): Promise<NamespaceLoader> {
|
||||
* over `http(s)://` or the filesystem (bare path or `file://`), identically.
|
||||
*/
|
||||
async function loadRegistry(mainUri: string, opts: FetchOpts): Promise<NamespaceLoader> {
|
||||
// Refuse cleartext http:// before the try so the scheme error isn't wrapped
|
||||
// in the misleading "not a directory" frame below.
|
||||
assertSecureFetchUrl(mainUri, "Registry URL");
|
||||
let text: string;
|
||||
try {
|
||||
text = await readText(mainUri, opts);
|
||||
@@ -149,6 +154,9 @@ async function loadRegistry(mainUri: string, opts: FetchOpts): Promise<Namespace
|
||||
throw new Error(`Module not found in registry: ${category}/${id}`);
|
||||
}
|
||||
const moduleUri = resolveModuleUri(mainUri, entry.path);
|
||||
// Backstop a malicious index that downgrades an https index to an
|
||||
// absolute http:// module URL (`new URL` would honor the cross-origin host).
|
||||
assertSecureFetchUrl(moduleUri, "Registry module URL");
|
||||
const body = await readText(moduleUri, opts);
|
||||
return ModuleSchema.parse(JSON.parse(body));
|
||||
},
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
import { assertSecureFetchUrl, clearInsecureWarningsForTests } from "./secure-url";
|
||||
|
||||
beforeEach(() => {
|
||||
clearInsecureWarningsForTests();
|
||||
delete process.env.STANZA_ALLOW_INSECURE_REGISTRY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete process.env.STANZA_ALLOW_INSECURE_REGISTRY;
|
||||
});
|
||||
|
||||
describe("assertSecureFetchUrl", () => {
|
||||
it("rejects a remote http:// URL without the opt-in", () => {
|
||||
expect(() => assertSecureFetchUrl("http://mirror.example/index.json", "Registry URL")).toThrow(
|
||||
/must use https:\/\//,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://stanza.tools/registry/index.json",
|
||||
"file:///tmp/reg/index.json",
|
||||
"/abs/path/index.json",
|
||||
"./relative/index.json",
|
||||
])("accepts non-cleartext sources (%s)", (uri) => {
|
||||
expect(() => assertSecureFetchUrl(uri, "Registry URL")).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://localhost:4000/index.json",
|
||||
"http://127.0.0.1:4873/pkg",
|
||||
"http://127.0.0.53/index.json",
|
||||
"http://[::1]:8080/index.json",
|
||||
])("accepts loopback http:// without the opt-in (%s)", (uri) => {
|
||||
expect(() => assertSecureFetchUrl(uri, "npm registry URL")).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not treat a host that merely starts with 127. as loopback", () => {
|
||||
expect(() => assertSecureFetchUrl("http://127.evil.com/index.json", "Registry URL")).toThrow(
|
||||
/must use https:\/\//,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a remote http:// URL and warns once when the opt-in is set", () => {
|
||||
process.env.STANZA_ALLOW_INSECURE_REGISTRY = "1";
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(() =>
|
||||
assertSecureFetchUrl("http://mirror.example/index.json", "Registry URL"),
|
||||
).not.toThrow();
|
||||
// Same endpoint a second time: still allowed, but not warned again.
|
||||
assertSecureFetchUrl("http://mirror.example/index.json", "Registry URL");
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("cleartext http://"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Env opt-out for cleartext *remote* registry/npm endpoints. Any non-empty
|
||||
* value enables it (same convention as `STANZA_NO_NPM_LOOKUP`); docs show `=1`.
|
||||
*/
|
||||
const ALLOW_INSECURE_ENV = "STANZA_ALLOW_INSECURE_REGISTRY";
|
||||
|
||||
/** Cleartext (non-TLS) `http://` — the only scheme we scrutinize for fetches. */
|
||||
function isCleartextHttp(uri: string): boolean {
|
||||
return /^http:\/\//i.test(uri);
|
||||
}
|
||||
|
||||
// 127.0.0.0/8 written as a literal IPv4 (not a `127.` prefix) so a hostile host
|
||||
// like `127.evil.com` can't masquerade as loopback.
|
||||
const IPV4_LOOPBACK = /^127(\.\d{1,3}){3}$/;
|
||||
|
||||
/**
|
||||
* True for an `http://` URL whose literal host is loopback. Loopback traffic
|
||||
* never leaves the machine, so there is no network path for an on-path attacker
|
||||
* — it's no more exposed than a local file. Matching the literal hostname (not
|
||||
* a resolved IP) keeps DNS rebinding from sneaking a remote host past the gate.
|
||||
*/
|
||||
function isLoopbackHttp(uri: string): boolean {
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(uri).hostname.toLowerCase();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
host = host.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
||||
return host === "localhost" || host === "::1" || IPV4_LOOPBACK.test(host);
|
||||
}
|
||||
|
||||
// Warn at most once per distinct endpoint so an opted-in user isn't spammed.
|
||||
const warnedInsecure = new Set<string>();
|
||||
|
||||
/**
|
||||
* Guard a remote fetch URL before it is contacted. Registry and npm payloads
|
||||
* get no integrity check beyond TLS, so a cleartext `http://` endpoint lets an
|
||||
* on-path attacker swap module content or steer dependency versions — RCE on
|
||||
* the next install/build. Reject remote `http://` unless the user knowingly
|
||||
* opts into an internal mirror via `STANZA_ALLOW_INSECURE_REGISTRY` (warned
|
||||
* once). `https://`, `file://`, bare filesystem paths, and loopback `http://`
|
||||
* always pass, so local-dev and air-gapped workflows are unaffected.
|
||||
*/
|
||||
export function assertSecureFetchUrl(uri: string, label: string): void {
|
||||
if (!isCleartextHttp(uri) || isLoopbackHttp(uri)) return;
|
||||
if (process.env[ALLOW_INSECURE_ENV]) {
|
||||
if (!warnedInsecure.has(uri)) {
|
||||
warnedInsecure.add(uri);
|
||||
console.warn(
|
||||
`⚠ ${label} uses cleartext http:// ("${uri}"). Its payload is not integrity-checked ` +
|
||||
`beyond TLS, so an on-path attacker could tamper with it. Allowed because ` +
|
||||
`${ALLOW_INSECURE_ENV} is set.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`${label} must use https:// (got "${uri}"). Registry and npm payloads are not ` +
|
||||
`integrity-checked beyond TLS, so cleartext http:// is refused. Use https://, a local ` +
|
||||
`file:// path, or set ${ALLOW_INSECURE_ENV}=1 if you knowingly need an internal http mirror.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Test-only: drop the once-per-endpoint warning state between cases. */
|
||||
export function clearInsecureWarningsForTests(): void {
|
||||
warnedInsecure.clear();
|
||||
}
|
||||
@@ -239,6 +239,8 @@ defineModule({
|
||||
});
|
||||
```
|
||||
|
||||
Env var `name` must be a valid dotenv/shell key (`^[A-Za-z_][A-Za-z0-9_]*$`), and neither `example` nor `description` may contain control characters or newlines — these values are written verbatim into the generated `.env.example`, so anything else is rejected at load time to prevent line injection.
|
||||
|
||||
### The `app` overlay
|
||||
|
||||
Package-home modules sometimes need to install a dep into the consuming app, not the package itself — e.g. shadcn's theme provider lives in `packages/ui/` but imports `next-themes` from `apps/web/`. Use the `app:` overlay for that:
|
||||
|
||||
@@ -93,9 +93,10 @@ These apply to the mutating verbs (`init`, `add`, `remove`):
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `STANZA_REGISTRY=<url-or-path>` — override the `@stanza` default namespace's source: the full URL or filesystem path to a registry's **main JSON file** (not a directory). For per-namespace third-party registries, use the `registries` field in `stanza.json` instead — see [Third-party registries](/docs/registry#third-party-registries).
|
||||
- `STANZA_REGISTRY=<url-or-path>` — override the `@stanza` default namespace's source: the full URL or filesystem path to a registry's **main JSON file** (not a directory). A remote URL must be `https://` (see `STANZA_ALLOW_INSECURE_REGISTRY` below). For per-namespace third-party registries, use the `registries` field in `stanza.json` instead — see [Third-party registries](/docs/registry#third-party-registries).
|
||||
- `STANZA_NO_NPM_LOOKUP=1` — skip npm version lookups and write dependency ranges verbatim.
|
||||
- `STANZA_NPM_REGISTRY=<url>` — override the npm registry used for version lookups.
|
||||
- `STANZA_NPM_REGISTRY=<url>` — override the npm registry used for version lookups. Must be `https://` (or a loopback host); a remote `http://` registry is refused unless `STANZA_ALLOW_INSECURE_REGISTRY=1` is set.
|
||||
- `STANZA_ALLOW_INSECURE_REGISTRY=1` — opt into cleartext `http://` registry and npm endpoints. By default Stanza refuses remote `http://` because registry and npm payloads have no integrity check beyond TLS, so an on-path attacker could swap module content or steer dependency versions. `https://`, `file://`, bare filesystem paths, and loopback (`localhost`/`127.0.0.1`) are always allowed; this flag is only for a trusted internal `http://` mirror, and the CLI prints a stderr warning when it's used.
|
||||
- `STANZA_TELEMETRY=0` / `DO_NOT_TRACK=1` — disable telemetry persistently. Telemetry is also auto-skipped in CI.
|
||||
- `STANZA_TELEMETRY_URL=<url>` — point telemetry at a self-hosted ingest endpoint instead of `https://stanza.tools/api/events`.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ A Stanza project contains one or more **apps** — each a buildable thing under
|
||||
```
|
||||
|
||||
- **`id`** — short, stable handle. Doubles as the workspace-package suffix (`@<your-app>/web`) and as the value you pass to `--app=<id>` in the CLI.
|
||||
- **`dir`** — repo-relative directory the app lives in.
|
||||
- **`dir`** — repo-relative directory the app lives in. Must stay inside the project: Stanza rejects a manifest whose `dir` is absolute or contains `..`, since the value is joined onto your repo root for every file it writes.
|
||||
- **`kind`** — `web` or `native`. Lets Stanza reject obviously-wrong installs (e.g. installing Next.js into a `kind: "native"` app).
|
||||
|
||||
`stanza init` scaffolds a single web app today (`{ id: "web", dir: "apps/web", kind: "web" }`). Multi-app projects — a web app and an Expo native app sharing a `packages/db/`, for example — are on the roadmap, and the schema is already shaped for them. App-home module records carry an `apps: ["<id>"]` field so `add`/`remove` know which app to target; package-home modules ship their core code once and route shims into the apps that consume them.
|
||||
|
||||
@@ -179,4 +179,6 @@ For consumers, declaring a registry is the full URL of its main JSON file in `st
|
||||
{ "registries": { "@acme": "https://reg.acme.example/index.json" } }
|
||||
```
|
||||
|
||||
A registry `url` must be served over `https://` — module payloads have no integrity check beyond TLS, so a cleartext `http://` registry is refused (it would let an on-path attacker tamper with the modules you install). `file://` URLs, bare filesystem paths, and loopback hosts (`localhost`/`127.0.0.1`) stay allowed for local development; set `STANZA_ALLOW_INSECURE_REGISTRY=1` only if you knowingly need a trusted internal `http://` mirror. A third-party registry that fails this check is skipped with a warning rather than breaking the whole CLI.
|
||||
|
||||
For authors, the full module surface — `defineModule`, templates, codemods, the build pipeline, hosting, namespace conventions, and a worked example — lives in the [Authoring manual](/docs/authoring).
|
||||
|
||||
@@ -2,6 +2,7 @@ import { assert, describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import {
|
||||
appsForRecord,
|
||||
compileManifestJsonSchema,
|
||||
CURRENT_MANIFEST_VERSION,
|
||||
declaredEnvNames,
|
||||
defaultWebApp,
|
||||
@@ -77,6 +78,87 @@ describe("StanzaManifestSchema", () => {
|
||||
).toThrow(/apps/);
|
||||
});
|
||||
|
||||
it("rejects an app whose `dir` escapes the project root", () => {
|
||||
const base = {
|
||||
version: CURRENT_MANIFEST_VERSION,
|
||||
projectShape: "monorepo",
|
||||
packageManager: "pnpm",
|
||||
name: "acme",
|
||||
modules: {},
|
||||
regions: {},
|
||||
} as const;
|
||||
for (const dir of ["../../etc", "a/../b", "/etc/cron.d", "C:\\Windows", ""]) {
|
||||
const result = StanzaManifestSchema.safeParse({
|
||||
...base,
|
||||
apps: [{ id: "web", dir, kind: "web" }],
|
||||
});
|
||||
expect(result.success, `dir=${JSON.stringify(dir)} should be rejected`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a manifest whose region file key escapes the project root", () => {
|
||||
const base = {
|
||||
version: CURRENT_MANIFEST_VERSION,
|
||||
projectShape: "monorepo",
|
||||
packageManager: "pnpm",
|
||||
name: "acme",
|
||||
apps: [{ id: "web", dir: "apps/web", kind: "web" }],
|
||||
modules: {},
|
||||
} as const;
|
||||
for (const file of ["../../etc/evil", "a/../b", "/etc/cron.d/x", "C:\\Windows\\x", ""]) {
|
||||
const result = StanzaManifestSchema.safeParse({
|
||||
...base,
|
||||
regions: { [file]: { file: "next@web" } },
|
||||
});
|
||||
expect(result.success, `region key=${JSON.stringify(file)} should be rejected`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts normal region file keys", () => {
|
||||
const result = StanzaManifestSchema.safeParse({
|
||||
version: CURRENT_MANIFEST_VERSION,
|
||||
projectShape: "monorepo",
|
||||
packageManager: "pnpm",
|
||||
name: "acme",
|
||||
apps: [{ id: "web", dir: "apps/web", kind: "web" }],
|
||||
modules: {},
|
||||
regions: {
|
||||
"apps/web/src/db/client.ts": { file: "postgres@web" },
|
||||
".env.example": { DATABASE_URL: "postgres@web" },
|
||||
"package.json": { "scripts.dev": "monorepo-turbo" },
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("still serializes to JSON Schema with the `dir` + region-key refines (published contract)", () => {
|
||||
// The field-level `.superRefine`s live in Zod's runtime layer; like the
|
||||
// existing top-level refine, they drop out of `z.toJSONSchema` rather than
|
||||
// throwing. `dir` and the `regions` keys stay plain strings/objects in the
|
||||
// published schema.
|
||||
const schema = compileManifestJsonSchema();
|
||||
const dir = (
|
||||
schema as { properties?: { apps?: { items?: { properties?: { dir?: { type?: string } } } } } }
|
||||
).properties?.apps?.items?.properties?.dir;
|
||||
expect(dir?.type).toBe("string");
|
||||
const regions = (schema as { properties?: { regions?: { type?: string } } }).properties
|
||||
?.regions;
|
||||
expect(regions?.type).toBe("object");
|
||||
});
|
||||
|
||||
it("accepts a normal nested app `dir`", () => {
|
||||
const result = StanzaManifestSchema.safeParse({
|
||||
version: CURRENT_MANIFEST_VERSION,
|
||||
projectShape: "monorepo",
|
||||
packageManager: "pnpm",
|
||||
name: "acme",
|
||||
apps: [{ id: "web", dir: "apps/web", kind: "web" }],
|
||||
modules: {},
|
||||
regions: {},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects two home:app framework records targeting the same app", () => {
|
||||
const result = StanzaManifestSchema.safeParse({
|
||||
...emptyManifest({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { safeRelativePath } from "@withstanza/utils";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
@@ -157,9 +158,19 @@ export type StanzaManifest = {
|
||||
readmeChecksum?: string;
|
||||
};
|
||||
|
||||
// Path-traversal defense for manifest-supplied paths that get joined onto the
|
||||
// project root — rejects `..`, absolute paths, and null bytes so a hostile or
|
||||
// attacker-authored manifest can't escape the project. Reused for `app.dir`
|
||||
// (every write into an app) and every `regions` file key (the `stanza remove`
|
||||
// delete target).
|
||||
const safeRelativePathSchema = z.string().superRefine((s, ctx) => {
|
||||
const err = safeRelativePath(s);
|
||||
if (err) ctx.addIssue({ code: "custom", message: err });
|
||||
});
|
||||
|
||||
const appSpecSchema = z.object({
|
||||
id: z.string(),
|
||||
dir: z.string(),
|
||||
dir: safeRelativePathSchema,
|
||||
kind: z.enum(APP_KINDS),
|
||||
}) satisfies z.ZodType<AppSpec>;
|
||||
|
||||
@@ -196,7 +207,11 @@ export const StanzaManifestSchema = z
|
||||
}),
|
||||
),
|
||||
),
|
||||
regions: z.record(z.string(), z.record(z.string(), z.string())),
|
||||
// Outer (file) keys are joined onto the project root and unlinked by
|
||||
// `stanza remove`, so they get the same traversal guard as `app.dir`.
|
||||
// Inner keys are region names (dot-paths inside the file), never
|
||||
// filesystem paths.
|
||||
regions: z.record(safeRelativePathSchema, z.record(z.string(), z.string())),
|
||||
registries: RegistriesSchema.optional(),
|
||||
readmeChecksum: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { ModuleSchema } from "./module";
|
||||
|
||||
// Minimal valid module shell; tests override `env` (and `app.env`) to exercise
|
||||
// `envVarSchema`. `ModuleSchema.safeParse` is the boundary that validates
|
||||
// fetched third-party modules, so this is where the injection guard must bite.
|
||||
function moduleWith(env: unknown, appEnv?: unknown): unknown {
|
||||
return {
|
||||
category: "auth",
|
||||
id: "x",
|
||||
label: "X",
|
||||
description: "d",
|
||||
version: "1.0.0",
|
||||
env,
|
||||
...(appEnv === undefined ? {} : { app: { env: appEnv } }),
|
||||
adapters: [{ key: "default", match: {} }],
|
||||
};
|
||||
}
|
||||
|
||||
describe("envVarSchema (via ModuleSchema)", () => {
|
||||
it("accepts a clean env var", () => {
|
||||
const res = ModuleSchema.safeParse(
|
||||
moduleWith([{ name: "DATABASE_URL", example: "postgres://localhost/db", required: true }]),
|
||||
);
|
||||
expect(res.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a clean env var with a description", () => {
|
||||
const res = ModuleSchema.safeParse(
|
||||
moduleWith([
|
||||
{ name: "API_KEY", example: "sk-...", required: true, description: "The API key." },
|
||||
]),
|
||||
);
|
||||
expect(res.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a name with a newline (line injection)", () => {
|
||||
const res = ModuleSchema.safeParse(
|
||||
moduleWith([{ name: "FOO\nMALICIOUS", example: "x", required: true }]),
|
||||
);
|
||||
expect(res.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a name that isn't a valid dotenv key", () => {
|
||||
const res = ModuleSchema.safeParse(
|
||||
moduleWith([{ name: "HAS-DASH", example: "x", required: true }]),
|
||||
);
|
||||
expect(res.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an example with an injected line", () => {
|
||||
const res = ModuleSchema.safeParse(
|
||||
moduleWith([{ name: "FOO", example: "\nBAR=baz", required: true }]),
|
||||
);
|
||||
expect(res.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a description with a control character", () => {
|
||||
const res = ModuleSchema.safeParse(
|
||||
moduleWith([{ name: "FOO", example: "x", required: true, description: "ok\nEVIL=1" }]),
|
||||
);
|
||||
expect(res.success).toBe(false);
|
||||
});
|
||||
|
||||
it("applies the same guard to the app.env overlay", () => {
|
||||
const res = ModuleSchema.safeParse(
|
||||
moduleWith(
|
||||
[{ name: "FOO", example: "x", required: true }],
|
||||
[{ name: "BAR\nEVIL", example: "y", required: true }],
|
||||
),
|
||||
);
|
||||
expect(res.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { safeRelativePath } from "@withstanza/utils";
|
||||
import { safeEnvName, safeEnvValue, safeRelativePath } from "@withstanza/utils";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
@@ -229,11 +229,24 @@ const jsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
|
||||
]),
|
||||
);
|
||||
|
||||
// Env names/values are written verbatim into `.env.example`. Without these
|
||||
// guards, a newline in `name`/`example`/`description` would inject extra lines
|
||||
// into the generated file (smuggling keys past `.env` parsers). Mirrors the
|
||||
// defense-in-depth check in `appendEnvVar`.
|
||||
const envNameSchema = z.string().superRefine((s, ctx) => {
|
||||
const err = safeEnvName(s);
|
||||
if (err) ctx.addIssue({ code: "custom", message: err });
|
||||
});
|
||||
const envValueSchema = z.string().superRefine((s, ctx) => {
|
||||
const err = safeEnvValue(s);
|
||||
if (err) ctx.addIssue({ code: "custom", message: err });
|
||||
});
|
||||
|
||||
const envVarSchema = z.object({
|
||||
name: z.string(),
|
||||
example: z.string(),
|
||||
name: envNameSchema,
|
||||
example: envValueSchema,
|
||||
required: z.boolean(),
|
||||
description: z.string().optional(),
|
||||
description: envValueSchema.optional(),
|
||||
});
|
||||
|
||||
const appInstallFieldsSchema = {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { appendEnvVar, safeEnvName, safeEnvValue } from "./env";
|
||||
|
||||
describe("safeEnvName", () => {
|
||||
it("accepts dotenv/shell keys", () => {
|
||||
expect(safeEnvName("DATABASE_URL")).toBeNull();
|
||||
expect(safeEnvName("_PRIVATE")).toBeNull();
|
||||
expect(safeEnvName("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY")).toBeNull();
|
||||
expect(safeEnvName("A1")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects empty names", () => {
|
||||
expect(safeEnvName("")).toMatch(/empty/);
|
||||
});
|
||||
|
||||
it("rejects names that don't match the key pattern", () => {
|
||||
expect(safeEnvName("1LEADING_DIGIT")).toMatch(/match/);
|
||||
expect(safeEnvName("HAS-DASH")).toMatch(/match/);
|
||||
expect(safeEnvName("HAS.DOT")).toMatch(/match/);
|
||||
expect(safeEnvName("HAS SPACE")).toMatch(/match/);
|
||||
expect(safeEnvName("HAS=EQUALS")).toMatch(/match/);
|
||||
});
|
||||
|
||||
it("rejects newline injection in the name", () => {
|
||||
expect(safeEnvName("FOO\nMALICIOUS")).toMatch(/match/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeEnvValue", () => {
|
||||
it("accepts ordinary example values", () => {
|
||||
expect(safeEnvValue("postgres://postgres:postgres@localhost:5432/db")).toBeNull();
|
||||
expect(safeEnvValue("sk-...")).toBeNull();
|
||||
expect(safeEnvValue("")).toBeNull();
|
||||
expect(safeEnvValue("with spaces, punctuation: ok!")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects control characters / newlines", () => {
|
||||
expect(safeEnvValue("\nBAR=baz")).toMatch(/control/);
|
||||
expect(safeEnvValue("a\rb")).toMatch(/control/);
|
||||
expect(safeEnvValue("a\tb")).toMatch(/control/);
|
||||
expect(safeEnvValue("a\0b")).toMatch(/control/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendEnvVar", () => {
|
||||
it("appends a new var after existing content with a blank-line separator", () => {
|
||||
expect(appendEnvVar("FOO=1", "BAR", "2")).toBe("FOO=1\n\nBAR=2");
|
||||
});
|
||||
|
||||
it("appends a leading description comment", () => {
|
||||
expect(appendEnvVar("", "BAR", "2", "the bar")).toBe("\n# the bar\nBAR=2");
|
||||
});
|
||||
|
||||
it("updates an existing var in place (idempotent)", () => {
|
||||
const once = appendEnvVar("# the bar\nBAR=old\n", "BAR", "new", "the bar");
|
||||
expect(once).toBe("# the bar\nBAR=new\n");
|
||||
// Re-applying the same write is a no-op.
|
||||
expect(appendEnvVar(once, "BAR", "new", "the bar")).toBe(once);
|
||||
});
|
||||
|
||||
it("rejects a name containing a newline", () => {
|
||||
expect(() => appendEnvVar("", "FOO\nMALICIOUS", "x")).toThrow(/name/);
|
||||
});
|
||||
|
||||
it("rejects an example containing a newline", () => {
|
||||
expect(() => appendEnvVar("", "FOO", "\nBAR=baz")).toThrow(/control/);
|
||||
});
|
||||
|
||||
it("rejects a description containing a newline", () => {
|
||||
expect(() => appendEnvVar("", "FOO", "x", "ok\nEVIL=1")).toThrow(/control/);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,55 @@
|
||||
// Dotenv/shell key: a leading letter or underscore, then word characters.
|
||||
// Anything else (newlines, `=`, spaces) can smuggle extra lines into
|
||||
// `.env.example` or break shell sourcing of the entry.
|
||||
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
// Char-scan rather than a regex literal: matches the null-byte style in
|
||||
// `safe-path.ts` and sidesteps the `no-control-regex` lint.
|
||||
function hasControlChar(input: string): boolean {
|
||||
for (const ch of input) {
|
||||
const code = ch.charCodeAt(0);
|
||||
if (code <= 0x1f || code === 0x7f) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate an env var name. Registry-supplied names flow verbatim into
|
||||
// `.env.example`; a name with a newline would inject extra `KEY=value` lines.
|
||||
// Returns an error message, or `null` on acceptance.
|
||||
export function safeEnvName(input: string): string | null {
|
||||
if (typeof input !== "string") return "env var name must be a string";
|
||||
if (input.length === 0) return "env var name cannot be empty";
|
||||
if (!ENV_NAME_RE.test(input)) {
|
||||
return "env var name must match ^[A-Za-z_][A-Za-z0-9_]*$";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate an env var `example` or `description`. Both are written verbatim
|
||||
// into `.env.example` (the latter as a `# comment`); a control character
|
||||
// (newline, CR, …) would inject extra lines and smuggle unexpected keys past
|
||||
// `.env` parsers. Returns an error message, or `null` on acceptance.
|
||||
export function safeEnvValue(input: string): string | null {
|
||||
if (typeof input !== "string") return "env var value must be a string";
|
||||
if (hasControlChar(input)) return "env var value cannot contain control characters";
|
||||
return null;
|
||||
}
|
||||
|
||||
function assertValid(err: string | null, value: string): void {
|
||||
if (err) throw new Error(`${err} (got ${JSON.stringify(value)})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently append an env var to `.env.example`-style text, returning the
|
||||
* new contents. Pure (no fs) so it backs both the CLI's `addEnvVar` and the
|
||||
* web builder's preview synthesis — the single source of truth for env-file
|
||||
* formatting. Updates an existing var in place; otherwise appends with a blank
|
||||
* line separator and an optional leading `# description` comment.
|
||||
*
|
||||
* Defense-in-depth: rejects malformed `name`/`example`/`description` so a
|
||||
* hostile module manifest can't inject extra lines, even if it somehow bypasses
|
||||
* schema validation. The schema (`envVarSchema`) enforces the same rules at the
|
||||
* registry boundary.
|
||||
*/
|
||||
export function appendEnvVar(
|
||||
contents: string,
|
||||
@@ -11,6 +57,10 @@ export function appendEnvVar(
|
||||
example: string,
|
||||
description?: string,
|
||||
): string {
|
||||
assertValid(safeEnvName(name), name);
|
||||
assertValid(safeEnvValue(example), example);
|
||||
if (description !== undefined) assertValid(safeEnvValue(description), description);
|
||||
|
||||
const lines = contents.split("\n");
|
||||
const existingIdx = lines.findIndex((line) => line.replace(/^#\s*/, "").startsWith(`${name}=`));
|
||||
const entry = description ? `# ${description}\n${name}=${example}` : `${name}=${example}`;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { safeRelativePath, assertSafeRelativePath } from "./safe-path";
|
||||
export { appendEnvVar } from "./env";
|
||||
export { appendEnvVar, safeEnvName, safeEnvValue } from "./env";
|
||||
|
||||
@@ -85,7 +85,7 @@ For `init --yes`, pass each category's module ids as a comma-separated value; si
|
||||
## Dependency Versions
|
||||
|
||||
- `init`/`add` bump each `^`/`~` dep range to the latest npm version satisfying it (keeping the modifier); other ranges and `workspace:*` are written as-is. Falls back to the declared range when offline.
|
||||
- `STANZA_NO_NPM_LOOKUP=1` skips lookups (verbatim ranges); `STANZA_NPM_REGISTRY=<url>` overrides the npm registry.
|
||||
- `STANZA_NO_NPM_LOOKUP=1` skips lookups (verbatim ranges); `STANZA_NPM_REGISTRY=<url>` overrides the npm registry (must be `https://`; remote `http://` is refused — see [Registries](#registries)).
|
||||
|
||||
## Safety Flags
|
||||
|
||||
@@ -129,6 +129,8 @@ Editing `stanza.json#registries` is an expected user-driven edit (the rest of th
|
||||
|
||||
`STANZA_REGISTRY=<url-or-path>` overrides the `@stanza` namespace's source only — set it to the **full URL or filesystem path to a registry's main JSON file** (not a directory), for a self-hosted mirror, air-gapped install, or CI fixture. It does NOT enable third-party modules.
|
||||
|
||||
**Cleartext is refused.** Registry and npm payloads carry no integrity check beyond TLS, so a remote `http://` endpoint — the `@stanza` default, `STANZA_REGISTRY`, any third-party `registries[*].url`, or `STANZA_NPM_REGISTRY` — is rejected. `https://`, `file://`, bare filesystem paths, and loopback (`localhost`/`127.0.0.1`) are always allowed. To knowingly use a trusted internal `http://` mirror, set `STANZA_ALLOW_INSECURE_REGISTRY=1`; the CLI then prints a one-time stderr warning. A third-party `http://` registry is skipped (with a warning) while the rest of the CLI keeps working; an `http://` value for `STANZA_REGISTRY` or `STANZA_NPM_REGISTRY` is a hard error.
|
||||
|
||||
## Telemetry
|
||||
|
||||
- The CLI sends anonymous usage events (command run, modules installed/removed — no PII, no identifier persisted). Disable per-invocation with `--no-telemetry`, or persistently with `STANZA_TELEMETRY=0` or `DO_NOT_TRACK=1`. Telemetry is auto-skipped in CI.
|
||||
@@ -144,7 +146,7 @@ Editing `stanza.json#registries` is an expected user-driven edit (the rest of th
|
||||
## Agent Rules
|
||||
|
||||
- Treat generated files as user-owned project code after Stanza writes them.
|
||||
- Don't synthesize `modules`, `regions`, or `readmeChecksum` in `stanza.json` — those are runner-managed. `registries`, `apps`, and `packageManager` are user config and may be edited.
|
||||
- Don't synthesize `modules`, `regions`, or `readmeChecksum` in `stanza.json` — those are runner-managed. `registries`, `apps`, and `packageManager` are user config and may be edited. Each app's `dir` must be a repo-relative path inside the project (no `..` or absolute paths); Stanza rejects the manifest otherwise.
|
||||
- Prefer CLI commands over reconstructing Stanza's template output yourself.
|
||||
- After mutating a project (or editing generated files), run `stanza doctor` to confirm `stanza.json` still matches the filesystem; it exits non-zero on drift.
|
||||
- When reporting results, include the exact command run, whether it wrote files, and any follow-up package-manager command needed.
|
||||
|
||||
Reference in New Issue
Block a user