Skip to content

Instantly share code, notes, and snippets.

@MarkKropf
Last active December 3, 2025 18:48
Show Gist options
  • Select an option

  • Save MarkKropf/d83a36184fc0c4b3eed362cdc6fe119e to your computer and use it in GitHub Desktop.

Select an option

Save MarkKropf/d83a36184fc0c4b3eed362cdc6fe119e to your computer and use it in GitHub Desktop.
Quick and dirty recursive patch of CVE-2025-55182 for nextjs apps that use pnpm
#!/usr/bin/env bash
set -euo pipefail
# CVE-2025-55182 / GHSA-9qr9-h5gf-34mp
# Focused patcher for:
# - react-server-dom-parcel / webpack / turbopack (React 19 RSC)
# - react and react-dom 19.0.0 / 19.1.0 / 19.1.1 / 19.2.0 -> fixed React versions
# - next 15.x / 16.0.x to the minimum patched version
#
# Usage:
# ./patch_cve55182.sh [max_parallel]
#
# Default max_parallel = 4
MAX_PARALLEL="${1:-4}"
echo "=== CVE-2025-55182 auto patcher (React RSC & Next.js) ==="
ROOT="$(pwd)"
echo "Root directory: ${ROOT}"
echo "Max parallel pnpm jobs: ${MAX_PARALLEL}"
echo
# Simple semantic version compare.
# Prints -1 if $1 < $2, 0 if equal, 1 if $1 > $2.
semver_cmp() {
local a="$1" b="$2"
local IFS=.
local ia=($a) ib=($b)
local i va vb
for ((i=0; i<3; i++)); do
va="${ia[i]:-0}"
vb="${ib[i]:-0}"
if ((10#$va < 10#$vb)); then
echo -1
return
elif ((10#$va > 10#$vb)); then
echo 1
return
fi
done
echo 0
}
# Decide if a "next" version is vulnerable and needs patching.
# Input: full version like 15.1.3
# Output: target patched version or empty if no change
next_target_version() {
local ver="$1"
local core="${ver%%-*}" # strip anything after dash
local major minor patch
IFS=. read major minor patch <<< "${core}"
if [[ -z "${major:-}" || -z "${minor:-}" || -z "${patch:-}" ]]; then
echo ""
return
fi
local key="${major}.${minor}"
local target=""
case "${key}" in
15.0) target="15.0.5" ;;
15.1) target="15.1.9" ;;
15.2) target="15.2.6" ;;
15.3) target="15.3.6" ;;
15.4) target="15.4.8" ;;
15.5) target="15.5.7" ;;
16.0) target="16.0.7" ;;
*) target="" ;;
esac
[[ -z "${target}" ]] && { echo ""; return; }
local cmp
cmp="$(semver_cmp "${core}" "${target}")"
if [[ "${cmp}" == "-1" ]]; then
echo "${target}"
else
echo ""
fi
}
# Mapping for React RSC packages and React core
react_rsc_target_version() {
local name="$1" ver="$2"
case "${name}" in
react-server-dom-parcel|react-server-dom-webpack|react-server-dom-turbopack)
case "${ver}" in
19.0.0) echo "19.0.1" ;;
19.1.0) echo "19.1.2" ;;
19.1.1) echo "19.1.2" ;;
19.2.0) echo "19.2.1" ;;
*) echo "" ;;
esac
;;
*)
echo ""
;;
esac
}
react_core_target_version() {
local name="$1" ver="$2"
case "${name}" in
react|react-dom)
case "${ver}" in
19.0.0) echo "19.0.1" ;;
19.1.0) echo "19.1.2" ;;
19.1.1) echo "19.1.2" ;;
19.2.0) echo "19.2.1" ;;
*) echo "" ;;
esac
;;
*)
echo ""
;;
esac
}
total_files=0
patched_files=0
total_changes=0
changed_dirs_file="$(mktemp)"
trap 'rm -f "${changed_dirs_file}"' EXIT
echo "Collecting package.json files (git-aware, respects .gitignore)..."
package_files=()
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
# Use git to respect .gitignore and .git/info/exclude
while IFS= read -r f; do
# still skip node_modules explicitly for sanity
case "$f" in
*node_modules/*) continue ;;
esac
package_files+=("./$f")
done < <(git ls-files -co --exclude-standard -- '*package.json')
else
# Fallback: no git repo, just use find, but still avoid node_modules
while IFS= read -r f; do
package_files+=("$f")
done < <(find . -type f -name package.json ! -path "*/node_modules/*" 2>/dev/null || true)
fi
total_files="${#package_files[@]}"
if (( total_files == 0 )); then
echo "No package.json files found. Nothing to do."
exit 0
fi
echo "Found ${total_files} package.json file(s) (excluding .gitignored paths and node_modules)."
echo
file_index=0
for pkg in "${package_files[@]}"; do
file_index=$((file_index + 1))
echo
echo "[${file_index}/${total_files}] Checking ${pkg}"
dir="$(cd "$(dirname "${pkg}")" && pwd)"
js_script='
const fs = require("fs");
const reactRscMap = {
"react-server-dom-parcel": {
"19.0.0": "19.0.1",
"19.1.0": "19.1.2",
"19.1.1": "19.1.2",
"19.2.0": "19.2.1",
},
"react-server-dom-webpack": {
"19.0.0": "19.0.1",
"19.1.0": "19.1.2",
"19.1.1": "19.1.2",
"19.2.0": "19.2.1",
},
"react-server-dom-turbopack": {
"19.0.0": "19.0.1",
"19.1.0": "19.1.2",
"19.1.1": "19.1.2",
"19.2.0": "19.2.1",
},
};
const reactCoreMap = {
"react": {
"19.0.0": "19.0.1",
"19.1.0": "19.1.2",
"19.1.1": "19.1.2",
"19.2.0": "19.2.1",
},
"react-dom": {
"19.0.0": "19.0.1",
"19.1.0": "19.1.2",
"19.1.1": "19.1.2",
"19.2.0": "19.2.1",
},
};
function nextTarget(v) {
if (typeof v !== "string") return "";
const core = v.split("-")[0].trim();
const parts = core.split(".");
if (parts.length < 3) return "";
const major = parseInt(parts[0], 10);
const minor = parseInt(parts[1], 10);
const patch = parseInt(parts[2], 10);
if (Number.isNaN(major) || Number.isNaN(minor) || Number.isNaN(patch)) return "";
const key = `${major}.${minor}`;
const map = {
"15.0": "15.0.5",
"15.1": "15.1.9",
"15.2": "15.2.6",
"15.3": "15.3.6",
"15.4": "15.4.8",
"15.5": "15.5.7",
"16.0": "16.0.7",
};
const target = map[key];
if (!target) return "";
function cmp(a, b) {
const pa = a.split(".");
const pb = b.split(".");
for (let i = 0; i < 3; i++) {
const va = parseInt(pa[i] || "0", 10);
const vb = parseInt(pb[i] || "0", 10);
if (va < vb) return -1;
if (va > vb) return 1;
}
return 0;
}
if (cmp(core, target) < 0) {
return target;
}
return "";
}
function reactRscTarget(name, version) {
const map = reactRscMap[name];
if (!map) return "";
return map[version] || "";
}
function reactCoreTarget(name, version) {
const map = reactCoreMap[name];
if (!map) return "";
return map[version] || "";
}
function updateBlock(block, blockName, log) {
if (!block || typeof block !== "object") return 0;
let changes = 0;
for (const [name, currentRaw] of Object.entries(block)) {
if (typeof currentRaw !== "string") continue;
const current = currentRaw.trim();
let prefix = "";
let bare = current;
const m = current.match(/^([~^])(.*)$/);
if (m) {
prefix = m[1];
bare = m[2].trim();
}
let newVersion = "";
// React RSC packages
const rsc = reactRscTarget(name, bare);
if (rsc) {
newVersion = rsc;
}
// React core (react and react-dom)
if (!newVersion) {
const core = reactCoreTarget(name, bare);
if (core) {
newVersion = core;
}
}
// Next.js
if (!newVersion && name === "next") {
const nTarget = nextTarget(bare);
if (nTarget) {
newVersion = nTarget;
}
}
if (newVersion) {
const newRaw = `${prefix}${newVersion}`;
block[name] = newRaw;
changes++;
log.push(` - ${blockName}: ${name} ${currentRaw} -> ${newRaw}`);
}
}
return changes;
}
const input = fs.readFileSync(0, "utf8");
let data;
try {
data = JSON.parse(input);
} catch (e) {
console.error(" ! Failed to parse JSON:", String(e));
process.exit(2);
}
const log = [];
let total = 0;
total += updateBlock(data.dependencies || {}, "dependencies", log);
total += updateBlock(data.devDependencies || {}, "devDependencies", log);
total += updateBlock(data.peerDependencies || {}, "peerDependencies", log);
total += updateBlock(data.optionalDependencies || {}, "optionalDependencies", log);
if (total > 0) {
for (const line of log) {
console.error(line);
}
console.error(` -> Updated ${total} package entr${total === 1 ? "y" : "ies"} in this file.`);
} else {
console.error(" -> No vulnerable versions found in this file.");
}
process.stdout.write(JSON.stringify(data, null, 2));
'
log_file="$(mktemp)"
if ! updated_json="$(node -e "${js_script}" < "${pkg}" 2> "${log_file}")"; then
echo " ! Node processing failed for ${pkg}"
cat "${log_file}" || true
rm -f "${log_file}"
continue
fi
cat "${log_file}" || true
if diff -q "${pkg}" - >/dev/null 2>&1 <<< "${updated_json}"; then
echo " (no changes to write)"
rm -f "${log_file}"
continue
fi
printf "%s\n" "${updated_json}" > "${pkg}"
patched_files=$((patched_files + 1))
changes_in_file="$(grep -E 'Updated [0-9]+ package entr' "${log_file}" | sed 's/.*Updated \([0-9][0-9]*\) package.*/\1/' | tail -n1 || true)"
if [[ -n "${changes_in_file:-}" ]]; then
total_changes=$((total_changes + changes_in_file))
fi
echo "${dir}" >> "${changed_dirs_file}"
rm -f "${log_file}"
done
changed_dirs=()
if [[ -s "${changed_dirs_file}" ]]; then
while IFS= read -r d; do
changed_dirs+=("$d")
done < <(sort -u "${changed_dirs_file}")
fi
echo
echo "=== Scan summary ==="
echo "Files scanned: ${total_files}"
echo "Files patched: ${patched_files}"
echo "Total changes: ${total_changes}"
echo "Dirs to rebuild: ${#changed_dirs[@]}"
echo
if (( ${#changed_dirs[@]} == 0 )); then
echo "No vulnerable React, React DOM, RSC, or Next.js versions were found. Exiting."
exit 0
fi
echo "Running pnpm install and pnpm build in patched directories..."
echo
running_jobs=0
pids=()
run_pnpm_in_dir() {
local d="$1"
(
set -e
echo
echo "[pnpm] [${d}] Starting..."
cd "${d}"
echo "[pnpm] [${d}] pnpm install"
if ! pnpm install; then
echo "[pnpm] [${d}] pnpm install FAILED"
exit 1
fi
echo "[pnpm] [${d}] pnpm build"
if ! pnpm build; then
echo "[pnpm] [${d}] pnpm build FAILED"
exit 1
fi
echo "[pnpm] [${d}] Completed successfully."
) &
pids+=("$!")
running_jobs=$((running_jobs + 1))
}
wait_for_any_job() {
while :; do
local i
for i in "${!pids[@]}"; do
local pid="${pids[$i]}"
if ! kill -0 "${pid}" 2>/dev/null; then
unset 'pids[i]'
running_jobs=$((running_jobs - 1))
return
fi
done
sleep 0.5
done
}
for d in "${changed_dirs[@]}"; do
echo "[queue] ${d}"
run_pnpm_in_dir "${d}"
while (( running_jobs >= MAX_PARALLEL )); do
wait_for_any_job
done
done
while (( running_jobs > 0 )); do
wait_for_any_job
done
echo
echo "All pnpm jobs finished."
echo "Done."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment