Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,9 @@ node_modules/
pnpm-lock.yaml
temp_*/
docs/
# Auto-generated from PR descriptions by the release tooling.
# Its contents carry markdown that does not always conform to prettier's
# preferences (e.g. literal `_*` sequences from release-note titles that
# prettier wants escaped to `\*`). Excluding avoids re-formatting on every
# release cut.
CHANGELOG.md
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ Some operations need explicit `isDryRun()` checks:

<!-- lore:019db09e-acae-76ae-8813-a317c0e6f6f9 -->

- **--config-from gated behind --allow-remote-config**: Craft's \`prepare --config-from \<branch>\` fetches \`.craft.yml\` from a remote git ref and feeds it to \`loadConfigurationFromString\`, which can execute arbitrary commands via preReleaseCommand. Now gated: \`assertRemoteConfigAllowed()\` in \`src/commands/prepare.ts\` throws ConfigurationError unless \`--allow-remote-config\` (or \`CRAFT_ALLOW_REMOTE_CONFIG=1\`) is set. When opted in, a warning is logged naming the branch. Exported helper is unit-tested; full \`prepareMain\` is not (heavy mock surface).
- **--config-from gate (--allow-remote-config) removed in #809**: Craft originally added \`--allow-remote-config\` / \`CRAFT_ALLOW_REMOTE_CONFIG=1\` in #794 as a required opt-in before \`--config-from \<branch>\` would load \`.craft.yml\` from a remote git ref. The gate was removed in #809 because: (1) in every realistic caller the branch is chosen by a release-workflow author on a protected branch so PR contributors can't influence it; (2) the local-file \`preReleaseCommand\` RCE vector exists regardless of \`--config-from\` — gating one but not the other adds friction without closing the class of risk; (3) the composite \`getsentry/craft\` action (action.yml) wired \`--config-from\` from \`craft_config_from_merge_target\` input but never passed the opt-in, so the gate broke every consumer who set that input since Craft 2.25.5. \`prepareMain\` now calls \`git.fetch\` / \`git.show\` / \`loadConfigurationFromString\` unconditionally when \`--config-from\` is present, and logs a warn-level reminder at the execution site naming the branch. Do NOT resurrect this as a hard gate — it's a false-sense-of-security footgun. The proper hardening is (a) the env allowlist in \`buildReleaseCommandEnv\` (limits exfiltration scope; does NOT prevent arbitrary command execution), and (b) upstream discipline on which refs \`merge_target\` can be set to.

### Gotcha

Expand All @@ -164,7 +164,7 @@ Some operations need explicit `isDryRun()` checks:

<!-- lore:019db0c1-fb90-7507-900b-896619ea120f -->

- **Craft .craft.yml discovery walks up from cwd — ancestor configs auto-load**: \`src/config.ts:findConfigFile()\` walks upward from \`cwd\` up to 1024 dirs looking for \`.craft.yml\`. Any stray \`.craft.yml\` in an ancestor (including \`$HOME\`) is loaded unconditionally and its \`preReleaseCommand\` executes. No \`--config\` flag exists to pin the path. Hardening: restrict discovery to the current git worktree root (first \`.git\` found), optionally require the file to be tracked by git, and add a \`--config \<path>\` flag that disables the walk. Complements the \`--allow-remote-config\` gate \[\[019db09e-acae-76ae-8813-a317c0e6f6f9]] and release env sanitization \[\[019db09e-ac9b-765d-a091-bb6bb512b987]].
- **Craft .craft.yml discovery walks up from cwd — ancestor configs auto-load**: \`src/config.ts:findConfigFile()\` walks upward from \`cwd\` up to 1024 dirs looking for \`.craft.yml\`. Any stray \`.craft.yml\` in an ancestor (including \`$HOME\`) is loaded unconditionally and its \`preReleaseCommand\` executes. No \`--config\` flag exists to pin the path. Hardening: restrict discovery to the current git worktree root (first \`.git\` found), optionally require the file to be tracked by git, and add a \`--config \<path>\` flag that disables the walk. Complements release env sanitization \[\[019db09e-ac9b-765d-a091-bb6bb512b987]].

<!-- lore:019db0c1-fb9f-719c-a903-14dc258a8cdd -->

Expand Down
36 changes: 1 addition & 35 deletions src/commands/__tests__/prepare.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import { vi, describe, test, expect, beforeEach, type Mock } from 'vitest';
import { spawnProcess } from '../../utils/system';
import {
runPreReleaseCommand,
checkVersionOrPart,
assertRemoteConfigAllowed,
} from '../prepare';
import { ConfigurationError } from '../../utils/errors';
import { runPreReleaseCommand, checkVersionOrPart } from '../prepare';

vi.mock('../../utils/system');

Expand Down Expand Up @@ -243,32 +238,3 @@ describe('checkVersionOrPart', () => {
}
});
});

describe('assertRemoteConfigAllowed', () => {
test('throws ConfigurationError when --allow-remote-config is not set', () => {
expect(() =>
assertRemoteConfigAllowed('untrusted-branch', false),
).toThrowError(ConfigurationError);
expect(() =>
assertRemoteConfigAllowed('untrusted-branch', undefined),
).toThrowError(ConfigurationError);
});

test('error message names the branch and the opt-in flag', () => {
try {
assertRemoteConfigAllowed('evil-branch', false);
throw new Error('expected throw');
} catch (err) {
const message = (err as Error).message;
expect(message).toContain('evil-branch');
expect(message).toContain('--allow-remote-config');
expect(message).toContain('CRAFT_ALLOW_REMOTE_CONFIG');
}
});

test('returns silently when opt-in is true', () => {
expect(() =>
assertRemoteConfigAllowed('trusted-branch', true),
).not.toThrow();
});
});
51 changes: 7 additions & 44 deletions src/commands/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,19 +123,9 @@ export const builder: CommandBuilder = (yargs: Argv) =>
})
.option('config-from', {
description:
'Load .craft.yml from the specified remote branch instead of local file. ' +
'Requires --allow-remote-config since the remote config can run arbitrary ' +
'commands via preReleaseCommand.',
'Load .craft.yml from the specified remote branch instead of local file',
type: 'string',
})
.option('allow-remote-config', {
description:
'Opt in to loading .craft.yml from a remote branch via --config-from. ' +
'Without this flag, --config-from is rejected because remotely-fetched ' +
'config is untrusted input that can execute arbitrary commands.',
type: 'boolean',
default: false,
})
.option('calver-offset', {
description:
'Days to go back for CalVer date calculation (overrides config)',
Expand All @@ -161,8 +151,6 @@ interface PrepareOptions {
publish: boolean;
/** Load config from specified remote branch */
configFrom?: string;
/** Explicit opt-in required to use --config-from */
allowRemoteConfig?: boolean;
/** Override CalVer offset (days to go back) */
calverOffset?: number;
}
Expand All @@ -173,33 +161,6 @@ interface PrepareOptions {
*/
const SLEEP_BEFORE_PUBLISH_SECONDS = 30;

/**
* Throws a {@link ConfigurationError} if `--config-from` was provided
* without the explicit `--allow-remote-config` opt-in.
*
* Remote-fetched `.craft.yml` is untrusted input: a malicious or
* compromised branch can set `preReleaseCommand` to an arbitrary shell
* command that Craft will execute with the allowlisted release env
* (including `GITHUB_TOKEN`). Requiring an explicit opt-in makes this
* tradeoff visible in CI logs and prevents accidental use.
*
* @param configFrom The branch name passed to `--config-from`.
* @param allowRemoteConfig Whether the user explicitly opted in.
*/
export function assertRemoteConfigAllowed(
configFrom: string,
allowRemoteConfig: boolean | undefined,
): void {
if (!allowRemoteConfig) {
throw new ConfigurationError(
`--config-from loads ${CONFIG_FILE_NAME} from a remote git ref, which ` +
`can execute arbitrary commands via preReleaseCommand. Pass ` +
`--allow-remote-config (or set CRAFT_ALLOW_REMOTE_CONFIG=1) to ` +
`confirm you trust the remote branch "${configFrom}".`,
);
}
}

/**
* Checks the provided version argument for validity
*
Expand Down Expand Up @@ -831,12 +792,14 @@ async function resolveVersion(
export async function prepareMain(argv: PrepareOptions): Promise<any> {
let git = await getGitClient();

// Handle --config-from: load config from remote branch
// Handle --config-from: load config from remote branch. The caller
// is responsible for ensuring the branch is trusted — the remote
// config's preReleaseCommand will be executed by Craft.
if (argv.configFrom) {
assertRemoteConfigAllowed(argv.configFrom, argv.allowRemoteConfig);
logger.warn(
`Loading configuration from remote branch "${argv.configFrom}" (opted in via --allow-remote-config). ` +
`Ensure this branch is trusted — its .craft.yml will configure commands Craft will execute.`,
`Loading .craft.yml from remote branch "${argv.configFrom}". ` +
`Its preReleaseCommand will be executed by Craft — ensure the ` +
`branch is trusted.`,
);
try {
await git.fetch([argv.remote, argv.configFrom]);
Expand Down
Loading