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:
2026-06-09 12:50:43 -04:00
parent 99ed11f5de
commit e776dc74f6
26 changed files with 863 additions and 17 deletions
@@ -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.
+6
View File
@@ -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.
+44 -1
View File
@@ -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(
+12 -1
View File
@@ -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(
+97 -1
View File
@@ -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 };
+89
View File
@@ -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)})`,
);
}
}
+34
View File
@@ -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();
});
});
+11 -2
View File
@@ -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),
+93
View File
@@ -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");
});
});
+8
View File
@@ -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));
},
+57
View File
@@ -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://"));
});
});
+68
View File
@@ -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();
}
+2
View File
@@ -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:
+3 -2
View File
@@ -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`.
+1 -1
View File
@@ -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.
+2
View File
@@ -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).
+82
View File
@@ -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({
+17 -2
View File
@@ -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(),
})
+75
View File
@@ -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);
});
});
+17 -4
View File
@@ -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 = {
+73
View File
@@ -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/);
});
});
+50
View File
@@ -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 -1
View File
@@ -1,2 +1,2 @@
export { safeRelativePath, assertSafeRelativePath } from "./safe-path";
export { appendEnvVar } from "./env";
export { appendEnvVar, safeEnvName, safeEnvValue } from "./env";
+4 -2
View File
@@ -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.