@hominis/fireforge 0.16.1 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## 0.16.0
4
4
 
5
+ ### Security — eval-driven hardening for 0.16.0
6
+
7
+ - **Release workflow — shell injection via `${{ inputs.version }}` interpolation.** `.github/workflows/release.yml` previously interpolated `${{ inputs.version }}` directly into `npm version "${{ inputs.version }}" --no-git-tag-version`, so anyone with `actions:write` could trigger `workflow_dispatch` with a crafted version string (e.g. `1.0.0"; <command> #`) and execute arbitrary shell inside the job. The release job carries `contents: write`, `id-token: write`, and the `npm` trusted-publishing environment, so that shell would have had an open door to the publish credentials. The fix routes `${{ inputs.version }}` through an `env: INPUT_VERSION:` block on the `Bump version` step and references `"$INPUT_VERSION"` inside the `run:` script, so GitHub Actions substitutes the value into the process environment rather than into the shell source. The `Tag and push` step gets the same treatment for `${{ steps.version.outputs.version }}` — defense-in-depth, since `npm version`'s semver check already filters that interpolation, but the pattern is consistent and cheap.
8
+ - **`fireforge config` — prototype pollution via sentinel key segments.** `mutateConfig` in `src/core/config-mutate.ts` walks a dot-separated key through `getOrCreateChildRecord(parent, segment)` with no filter on `__proto__`, `constructor`, or `prototype`. `fireforge config __proto__.polluted 1 --force` therefore reached `parent["__proto__"]` and wrote a plain property onto `Object.prototype`, polluting every object in the Node process for the rest of the run. `--force` was the motivating pathway (the strict path guard rejects unknown top-level keys for non-sentinels), but the raw sink was also publicly re-exported from `src/core/config.ts`, widening the blast radius to any future caller. The fix rejects sentinel segments up-front in `mutateConfig` with a `ConfigError` before any clone or mutation — a single guard at the entry point covers both the descent loop and the final leaf assignment, and surfaces to the CLI as a normal "invalid key" failure rather than a crash. `readJson`'s existing reviver already strips the sentinels from loads, so input configs can't arrive pre-polluted.
9
+ - **`fireforge wire --dom` — asymmetric newline marker parser projection drift.** `parseHunksForFile` in `src/core/patch-parse.ts` tracked `` as a single `noNewlineAtEnd: boolean` — collapsing the old-side and new-side markers into one flag. Asymmetric trailing-newline changes (e.g. removing the newline from the old side while the new side keeps one, or vice versa) were indistinguishable from the symmetric case, so `applyPatchToContent` produced content that disagreed with `git apply` on whether to emit a trailing newline. The projection drift surfaced as phantom entries in `fireforge status` and wipe-safe reprojection work in `fireforge import` for patches that were otherwise clean. The fix splits the field into `noNewlineAtEndOld` / `noNewlineAtEndNew` and peeks the body line that the marker trails (`-` → old-only, `+` → new-only, ` ` context → both), and `applyPatchToContent` now reads `noNewlineAtEndNew` for the output-side newline decision because the content we emit corresponds to the new side. `extractNewFileContentFromDiff` is unchanged — new-file patches only contain `+` lines, so its separate `hasNoNewlineMarker` local is already correct by construction.
10
+ - **`fireforge wire --dom` — engine-relative inputs probed against CWD.** `src/commands/wire.ts` passed the raw `stripEnginePrefix(options.dom)` result to `pathExists` without joining `paths.engine` first, so a relative `--dom browser/base/content/foo.inc.xhtml` was probed inside the operator's shell directory and failed "DOM fragment file not found" even when the file existed in the correct engine location. The follow-on `isPathInsideRoot` and `toRootRelativePath` calls worked by coincidence — they internally `resolve(paths.engine, candidate)`, which masked the bug past the existence probe but only because those calls never hit the filesystem. The fix mirrors the pattern already in use in `src/commands/register.ts`: when the candidate is absolute probe it as-is, otherwise `join(paths.engine, candidate)` first. The error message still echoes the original operator input (not the internal joined path) so it remains copy-pasteable back into the CLI.
11
+
5
12
  ### Config — `--force`-written keys are now readable
6
13
 
7
14
  - `fireforge config <key>` (read mode) now consults the raw `fireforge.json` document instead of the validated, typed config that `loadConfig` produces. Before this change, `fireforge config totallyUnknown value --force` succeeded (the key was persisted to disk via `writeConfigDocument`), but the corresponding `fireforge config totallyUnknown` read threw `Unknown config key: totallyUnknown` because `validateConfig` rebuilds a clean object containing only the schema-known fields — the forced key survived on disk but was invisible to the typed read path. A new `loadRawConfigDocument` helper in `src/core/config.ts` returns the raw JSON record, and the command's read branch now traverses that document. Writes are unaffected: schema validation still enforces shape for known keys, and `--force` continues to be the escape hatch for unknown keys.
@@ -135,7 +135,20 @@ export async function wireCommand(projectRoot, name, options = {}) {
135
135
  const domCandidate = isExplicitAbsolutePath(options.dom)
136
136
  ? options.dom
137
137
  : stripEnginePrefix(options.dom);
138
- if (!(await pathExists(domCandidate))) {
138
+ // `pathExists` resolves relative paths against CWD, so an engine-
139
+ // relative `domCandidate` (e.g. `browser/base/content/foo.inc.xhtml`)
140
+ // would be probed inside the operator's shell directory rather than
141
+ // the engine root and fail "DOM fragment file not found" even when
142
+ // the file is sitting at engine/<path>. Mirror `register.ts`: probe
143
+ // the absolute path as-is, otherwise join with `paths.engine` first.
144
+ // The `isPathInsideRoot` / `toRootRelativePath` calls below keep
145
+ // operating on `domCandidate` because they internally resolve
146
+ // relative candidates against the engine root, which matches the
147
+ // probe path we just built.
148
+ const domProbePath = isExplicitAbsolutePath(domCandidate)
149
+ ? domCandidate
150
+ : join(paths.engine, domCandidate);
151
+ if (!(await pathExists(domProbePath))) {
139
152
  throw new InvalidArgumentError(`DOM fragment file not found: ${options.dom}`, 'dom');
140
153
  }
141
154
  if (!isPathInsideRoot(paths.engine, domCandidate)) {
@@ -14,6 +14,23 @@ function cloneConfigDocument(config) {
14
14
  }
15
15
  return cloned;
16
16
  }
17
+ /**
18
+ * Key segments that would walk into or rewrite the object prototype chain
19
+ * if used as plain property names. Blocked up-front so the descent in
20
+ * {@link mutateConfig} cannot be weaponized to mutate `Object.prototype`
21
+ * process-wide — e.g. `fireforge config __proto__.polluted 1 --force`
22
+ * would otherwise land in `getOrCreateChildRecord(raw, "__proto__")`.
23
+ */
24
+ const SENTINEL_KEY_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
25
+ function assertNoSentinelSegments(key, parts) {
26
+ for (const part of parts) {
27
+ if (SENTINEL_KEY_SEGMENTS.has(part)) {
28
+ throw new ConfigError(`Config key "${key}" contains a reserved segment "${part}". ` +
29
+ 'Segments "__proto__", "constructor", and "prototype" are not permitted ' +
30
+ 'because they would mutate the object prototype chain.');
31
+ }
32
+ }
33
+ }
17
34
  function getOrCreateChildRecord(parent, key) {
18
35
  const existing = parent[key];
19
36
  if (isObject(existing)) {
@@ -24,8 +41,13 @@ function getOrCreateChildRecord(parent, key) {
24
41
  return child;
25
42
  }
26
43
  export function mutateConfig(config, key, value, skipValidation = false) {
27
- const raw = cloneConfigDocument(config);
28
44
  const parts = key.split('.');
45
+ // Reject prototype-chain sentinel segments before any write so
46
+ // `--force` cannot be used to mutate Object.prototype. This guard must
47
+ // run against the original key parts, not any subset — the final leaf
48
+ // assignment `current[lastPart] = value` would otherwise stay vulnerable.
49
+ assertNoSentinelSegments(key, parts);
50
+ const raw = cloneConfigDocument(config);
29
51
  let current = raw;
30
52
  for (let i = 0; i < parts.length - 1; i++) {
31
53
  const part = parts[i];
@@ -24,19 +24,30 @@ export declare function isNewFileInPatch(patchContent: string, targetFile: strin
24
24
  */
25
25
  export declare function extractAffectedFiles(diffContent: string): string[];
26
26
  /**
27
- * Parses hunks from a patch file for a specific target file.
28
- * @param patchContent - The full patch content
29
- * @param targetFile - The file path to extract hunks for
30
- * @returns Array of hunk objects with line info and changes
27
+ * A single parsed hunk. `noNewlineAtEndOld` / `noNewlineAtEndNew` track the
28
+ * `` marker per side the marker is a trailing
29
+ * annotation on the immediately preceding body line, and a `-` precedent
30
+ * sets only the old-side flag, a `+` sets only the new-side flag, and a
31
+ * context ` ` line sets both. Collapsing the two into one boolean makes the
32
+ * projection disagree with `git apply` on asymmetric trailing-newline
33
+ * changes (e.g. removing a newline on one side but not the other).
31
34
  */
32
- export declare function parseHunksForFile(patchContent: string, targetFile: string): Array<{
35
+ export interface ParsedHunk {
33
36
  oldStart: number;
34
37
  oldCount: number;
35
38
  newStart: number;
36
39
  newCount: number;
37
40
  lines: string[];
38
- noNewlineAtEnd: boolean;
39
- }>;
41
+ noNewlineAtEndOld: boolean;
42
+ noNewlineAtEndNew: boolean;
43
+ }
44
+ /**
45
+ * Parses hunks from a patch file for a specific target file.
46
+ * @param patchContent - The full patch content
47
+ * @param targetFile - The file path to extract hunks for
48
+ * @returns Array of hunk objects with line info and changes
49
+ */
50
+ export declare function parseHunksForFile(patchContent: string, targetFile: string): ParsedHunk[];
40
51
  /**
41
52
  * Extracts conflicting file paths from git apply error message.
42
53
  */
@@ -104,14 +104,36 @@ export function parseHunksForFile(patchContent, targetFile) {
104
104
  newStart: parseInt(hunkMatch[3] ?? '0', 10),
105
105
  newCount: parseInt(hunkMatch[4] ?? '1', 10),
106
106
  lines: [],
107
- noNewlineAtEnd: false,
107
+ noNewlineAtEndOld: false,
108
+ noNewlineAtEndNew: false,
108
109
  };
109
110
  continue;
110
111
  }
111
112
  // Collect hunk lines
112
113
  if (currentHunk) {
113
114
  if (line === '\') {
114
- currentHunk.noNewlineAtEnd = true;
115
+ // The marker is an annotation on the immediately preceding body
116
+ // line. Peek the last collected line to decide which side(s) the
117
+ // annotation applies to — a single boolean cannot represent the
118
+ // asymmetric case where only one side lacks the trailing newline.
119
+ const previous = currentHunk.lines[currentHunk.lines.length - 1] ?? '';
120
+ if (previous.startsWith('-')) {
121
+ currentHunk.noNewlineAtEndOld = true;
122
+ }
123
+ else if (previous.startsWith('+')) {
124
+ currentHunk.noNewlineAtEndNew = true;
125
+ }
126
+ else if (previous.startsWith(' ')) {
127
+ // Context line: present in both sides, so the trailing-newline
128
+ // absence applies to both. This is rare (it only happens when
129
+ // the hunk ends on an unchanged line that itself is the last
130
+ // line of the file) but real — git emits it.
131
+ currentHunk.noNewlineAtEndOld = true;
132
+ currentHunk.noNewlineAtEndNew = true;
133
+ }
134
+ // If the marker appears with no preceding body line (malformed
135
+ // diff), leave both flags false — the downstream apply logic
136
+ // will still produce a defined result.
115
137
  }
116
138
  else if (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')) {
117
139
  currentHunk.lines.push(line);
@@ -105,7 +105,10 @@ export async function applyPatchToContent(content, patchPath, targetFile) {
105
105
  const sortedHunks = [...hunks].sort((a, b) => b.oldStart - a.oldStart);
106
106
  // The "no newline at end" marker applies to the last hunk in file order
107
107
  // (highest oldStart), which is the *first* hunk in our reverse-sorted array.
108
- const lastHunkNoNewline = sortedHunks[0]?.noNewlineAtEnd ?? false;
108
+ // We read the new-side flag because the output we produce corresponds to
109
+ // the new side; asymmetric diffs (old lacks newline, new has one — or
110
+ // vice versa) would otherwise disagree with `git apply`.
111
+ const lastHunkNoNewline = sortedHunks[0]?.noNewlineAtEndNew ?? false;
109
112
  for (const hunk of sortedHunks) {
110
113
  const newLines = [];
111
114
  // Compute actual old-line count from hunk body for cross-check
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",