Skip to content

Commit 61ad9a8

Browse files
fix(installer): refuse to chown through a symlinked ~/.local (security)
The #306 root-only ownership repair ran chown on ~/.local and ~/.local/bin without a symlink guard. If an untrusted target user pre-staged ~/.local (or ~/.local/bin) as a symlink (e.g. -> /etc) before a root install, the chown would follow it and transfer ownership of the link target to that user — a local privilege escalation. chown -h/nofollow isn't portable, so the repair now refuses (with a warning) when either path is already a symlink. Regenerated all install scripts; manifest drift contract stays clean (typecheck + check-manifest-drift both green). Co-Authored-By: Claude <noreply@anthropic.com>
1 parent edaee4f commit 61ad9a8

16 files changed

Lines changed: 256 additions & 96 deletions

packages/manifest/src/generate.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -306,14 +306,24 @@ if [[ "\${BASH_SOURCE[0]}" = "\${0}" ]]; then
306306
# deliberately non-recursive: only the two directories themselves are
307307
# touched, never their contents.
308308
if [[ \$EUID -eq 0 ]] && [[ -n "\${TARGET_USER:-}" ]] && [[ "\${TARGET_USER}" != "root" ]]; then
309-
_acfs_repair_mkdir="\$(_acfs_system_binary_path mkdir 2>/dev/null || true)"
310-
_acfs_repair_chown="\$(_acfs_system_binary_path chown 2>/dev/null || true)"
311-
if [[ -n "\$_acfs_repair_mkdir" ]] && [[ -n "\$_acfs_repair_chown" ]]; then
312-
if "\$_acfs_repair_mkdir" -p "\$TARGET_HOME/.local/bin" 2>/dev/null; then
313-
"\$_acfs_repair_chown" "\${TARGET_USER}" "\$TARGET_HOME/.local" "\$TARGET_HOME/.local/bin" 2>/dev/null || true
309+
# SECURITY: never chown through a symlink. If an untrusted target user
310+
# pre-staged ~/.local or ~/.local/bin as a symlink (e.g. -> /etc) before
311+
# the root install, a chown that follows it would transfer ownership of
312+
# the link target to them (local privilege escalation). chown -h /
313+
# nofollow is not portable, so refuse the repair entirely when either
314+
# path already exists as a symlink.
315+
if [[ -L "\$TARGET_HOME/.local" ]] || [[ -L "\$TARGET_HOME/.local/bin" ]]; then
316+
log_warn "Skipping ~/.local ownership repair: \$TARGET_HOME/.local or .local/bin is a symlink (refusing to chown through it)"
317+
else
318+
_acfs_repair_mkdir="\$(_acfs_system_binary_path mkdir 2>/dev/null || true)"
319+
_acfs_repair_chown="\$(_acfs_system_binary_path chown 2>/dev/null || true)"
320+
if [[ -n "\$_acfs_repair_mkdir" ]] && [[ -n "\$_acfs_repair_chown" ]]; then
321+
if "\$_acfs_repair_mkdir" -p "\$TARGET_HOME/.local/bin" 2>/dev/null; then
322+
"\$_acfs_repair_chown" "\${TARGET_USER}" "\$TARGET_HOME/.local" "\$TARGET_HOME/.local/bin" 2>/dev/null || true
323+
fi
314324
fi
325+
unset _acfs_repair_mkdir _acfs_repair_chown
315326
fi
316-
unset _acfs_repair_mkdir _acfs_repair_chown
317327
fi
318328
fi
319329

scripts/generated/doctor_checks.sh

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -264,14 +264,24 @@ if [[ "${BASH_SOURCE[0]}" = "${0}" ]]; then
264264
# deliberately non-recursive: only the two directories themselves are
265265
# touched, never their contents.
266266
if [[ $EUID -eq 0 ]] && [[ -n "${TARGET_USER:-}" ]] && [[ "${TARGET_USER}" != "root" ]]; then
267-
_acfs_repair_mkdir="$(_acfs_system_binary_path mkdir 2>/dev/null || true)"
268-
_acfs_repair_chown="$(_acfs_system_binary_path chown 2>/dev/null || true)"
269-
if [[ -n "$_acfs_repair_mkdir" ]] && [[ -n "$_acfs_repair_chown" ]]; then
270-
if "$_acfs_repair_mkdir" -p "$TARGET_HOME/.local/bin" 2>/dev/null; then
271-
"$_acfs_repair_chown" "${TARGET_USER}" "$TARGET_HOME/.local" "$TARGET_HOME/.local/bin" 2>/dev/null || true
267+
# SECURITY: never chown through a symlink. If an untrusted target user
268+
# pre-staged ~/.local or ~/.local/bin as a symlink (e.g. -> /etc) before
269+
# the root install, a chown that follows it would transfer ownership of
270+
# the link target to them (local privilege escalation). chown -h /
271+
# nofollow is not portable, so refuse the repair entirely when either
272+
# path already exists as a symlink.
273+
if [[ -L "$TARGET_HOME/.local" ]] || [[ -L "$TARGET_HOME/.local/bin" ]]; then
274+
log_warn "Skipping ~/.local ownership repair: $TARGET_HOME/.local or .local/bin is a symlink (refusing to chown through it)"
275+
else
276+
_acfs_repair_mkdir="$(_acfs_system_binary_path mkdir 2>/dev/null || true)"
277+
_acfs_repair_chown="$(_acfs_system_binary_path chown 2>/dev/null || true)"
278+
if [[ -n "$_acfs_repair_mkdir" ]] && [[ -n "$_acfs_repair_chown" ]]; then
279+
if "$_acfs_repair_mkdir" -p "$TARGET_HOME/.local/bin" 2>/dev/null; then
280+
"$_acfs_repair_chown" "${TARGET_USER}" "$TARGET_HOME/.local" "$TARGET_HOME/.local/bin" 2>/dev/null || true
281+
fi
272282
fi
283+
unset _acfs_repair_mkdir _acfs_repair_chown
273284
fi
274-
unset _acfs_repair_mkdir _acfs_repair_chown
275285
fi
276286
fi
277287

scripts/generated/install_acfs.sh

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -264,14 +264,24 @@ if [[ "${BASH_SOURCE[0]}" = "${0}" ]]; then
264264
# deliberately non-recursive: only the two directories themselves are
265265
# touched, never their contents.
266266
if [[ $EUID -eq 0 ]] && [[ -n "${TARGET_USER:-}" ]] && [[ "${TARGET_USER}" != "root" ]]; then
267-
_acfs_repair_mkdir="$(_acfs_system_binary_path mkdir 2>/dev/null || true)"
268-
_acfs_repair_chown="$(_acfs_system_binary_path chown 2>/dev/null || true)"
269-
if [[ -n "$_acfs_repair_mkdir" ]] && [[ -n "$_acfs_repair_chown" ]]; then
270-
if "$_acfs_repair_mkdir" -p "$TARGET_HOME/.local/bin" 2>/dev/null; then
271-
"$_acfs_repair_chown" "${TARGET_USER}" "$TARGET_HOME/.local" "$TARGET_HOME/.local/bin" 2>/dev/null || true
267+
# SECURITY: never chown through a symlink. If an untrusted target user
268+
# pre-staged ~/.local or ~/.local/bin as a symlink (e.g. -> /etc) before
269+
# the root install, a chown that follows it would transfer ownership of
270+
# the link target to them (local privilege escalation). chown -h /
271+
# nofollow is not portable, so refuse the repair entirely when either
272+
# path already exists as a symlink.
273+
if [[ -L "$TARGET_HOME/.local" ]] || [[ -L "$TARGET_HOME/.local/bin" ]]; then
274+
log_warn "Skipping ~/.local ownership repair: $TARGET_HOME/.local or .local/bin is a symlink (refusing to chown through it)"
275+
else
276+
_acfs_repair_mkdir="$(_acfs_system_binary_path mkdir 2>/dev/null || true)"
277+
_acfs_repair_chown="$(_acfs_system_binary_path chown 2>/dev/null || true)"
278+
if [[ -n "$_acfs_repair_mkdir" ]] && [[ -n "$_acfs_repair_chown" ]]; then
279+
if "$_acfs_repair_mkdir" -p "$TARGET_HOME/.local/bin" 2>/dev/null; then
280+
"$_acfs_repair_chown" "${TARGET_USER}" "$TARGET_HOME/.local" "$TARGET_HOME/.local/bin" 2>/dev/null || true
281+
fi
272282
fi
283+
unset _acfs_repair_mkdir _acfs_repair_chown
273284
fi
274-
unset _acfs_repair_mkdir _acfs_repair_chown
275285
fi
276286
fi
277287

scripts/generated/install_agents.sh

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -264,14 +264,24 @@ if [[ "${BASH_SOURCE[0]}" = "${0}" ]]; then
264264
# deliberately non-recursive: only the two directories themselves are
265265
# touched, never their contents.
266266
if [[ $EUID -eq 0 ]] && [[ -n "${TARGET_USER:-}" ]] && [[ "${TARGET_USER}" != "root" ]]; then
267-
_acfs_repair_mkdir="$(_acfs_system_binary_path mkdir 2>/dev/null || true)"
268-
_acfs_repair_chown="$(_acfs_system_binary_path chown 2>/dev/null || true)"
269-
if [[ -n "$_acfs_repair_mkdir" ]] && [[ -n "$_acfs_repair_chown" ]]; then
270-
if "$_acfs_repair_mkdir" -p "$TARGET_HOME/.local/bin" 2>/dev/null; then
271-
"$_acfs_repair_chown" "${TARGET_USER}" "$TARGET_HOME/.local" "$TARGET_HOME/.local/bin" 2>/dev/null || true
267+
# SECURITY: never chown through a symlink. If an untrusted target user
268+
# pre-staged ~/.local or ~/.local/bin as a symlink (e.g. -> /etc) before
269+
# the root install, a chown that follows it would transfer ownership of
270+
# the link target to them (local privilege escalation). chown -h /
271+
# nofollow is not portable, so refuse the repair entirely when either
272+
# path already exists as a symlink.
273+
if [[ -L "$TARGET_HOME/.local" ]] || [[ -L "$TARGET_HOME/.local/bin" ]]; then
274+
log_warn "Skipping ~/.local ownership repair: $TARGET_HOME/.local or .local/bin is a symlink (refusing to chown through it)"
275+
else
276+
_acfs_repair_mkdir="$(_acfs_system_binary_path mkdir 2>/dev/null || true)"
277+
_acfs_repair_chown="$(_acfs_system_binary_path chown 2>/dev/null || true)"
278+
if [[ -n "$_acfs_repair_mkdir" ]] && [[ -n "$_acfs_repair_chown" ]]; then
279+
if "$_acfs_repair_mkdir" -p "$TARGET_HOME/.local/bin" 2>/dev/null; then
280+
"$_acfs_repair_chown" "${TARGET_USER}" "$TARGET_HOME/.local" "$TARGET_HOME/.local/bin" 2>/dev/null || true
281+
fi
272282
fi
283+
unset _acfs_repair_mkdir _acfs_repair_chown
273284
fi
274-
unset _acfs_repair_mkdir _acfs_repair_chown
275285
fi
276286
fi
277287

scripts/generated/install_all.sh

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -264,14 +264,24 @@ if [[ "${BASH_SOURCE[0]}" = "${0}" ]]; then
264264
# deliberately non-recursive: only the two directories themselves are
265265
# touched, never their contents.
266266
if [[ $EUID -eq 0 ]] && [[ -n "${TARGET_USER:-}" ]] && [[ "${TARGET_USER}" != "root" ]]; then
267-
_acfs_repair_mkdir="$(_acfs_system_binary_path mkdir 2>/dev/null || true)"
268-
_acfs_repair_chown="$(_acfs_system_binary_path chown 2>/dev/null || true)"
269-
if [[ -n "$_acfs_repair_mkdir" ]] && [[ -n "$_acfs_repair_chown" ]]; then
270-
if "$_acfs_repair_mkdir" -p "$TARGET_HOME/.local/bin" 2>/dev/null; then
271-
"$_acfs_repair_chown" "${TARGET_USER}" "$TARGET_HOME/.local" "$TARGET_HOME/.local/bin" 2>/dev/null || true
267+
# SECURITY: never chown through a symlink. If an untrusted target user
268+
# pre-staged ~/.local or ~/.local/bin as a symlink (e.g. -> /etc) before
269+
# the root install, a chown that follows it would transfer ownership of
270+
# the link target to them (local privilege escalation). chown -h /
271+
# nofollow is not portable, so refuse the repair entirely when either
272+
# path already exists as a symlink.
273+
if [[ -L "$TARGET_HOME/.local" ]] || [[ -L "$TARGET_HOME/.local/bin" ]]; then
274+
log_warn "Skipping ~/.local ownership repair: $TARGET_HOME/.local or .local/bin is a symlink (refusing to chown through it)"
275+
else
276+
_acfs_repair_mkdir="$(_acfs_system_binary_path mkdir 2>/dev/null || true)"
277+
_acfs_repair_chown="$(_acfs_system_binary_path chown 2>/dev/null || true)"
278+
if [[ -n "$_acfs_repair_mkdir" ]] && [[ -n "$_acfs_repair_chown" ]]; then
279+
if "$_acfs_repair_mkdir" -p "$TARGET_HOME/.local/bin" 2>/dev/null; then
280+
"$_acfs_repair_chown" "${TARGET_USER}" "$TARGET_HOME/.local" "$TARGET_HOME/.local/bin" 2>/dev/null || true
281+
fi
272282
fi
283+
unset _acfs_repair_mkdir _acfs_repair_chown
273284
fi
274-
unset _acfs_repair_mkdir _acfs_repair_chown
275285
fi
276286
fi
277287

scripts/generated/install_base.sh

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -264,14 +264,24 @@ if [[ "${BASH_SOURCE[0]}" = "${0}" ]]; then
264264
# deliberately non-recursive: only the two directories themselves are
265265
# touched, never their contents.
266266
if [[ $EUID -eq 0 ]] && [[ -n "${TARGET_USER:-}" ]] && [[ "${TARGET_USER}" != "root" ]]; then
267-
_acfs_repair_mkdir="$(_acfs_system_binary_path mkdir 2>/dev/null || true)"
268-
_acfs_repair_chown="$(_acfs_system_binary_path chown 2>/dev/null || true)"
269-
if [[ -n "$_acfs_repair_mkdir" ]] && [[ -n "$_acfs_repair_chown" ]]; then
270-
if "$_acfs_repair_mkdir" -p "$TARGET_HOME/.local/bin" 2>/dev/null; then
271-
"$_acfs_repair_chown" "${TARGET_USER}" "$TARGET_HOME/.local" "$TARGET_HOME/.local/bin" 2>/dev/null || true
267+
# SECURITY: never chown through a symlink. If an untrusted target user
268+
# pre-staged ~/.local or ~/.local/bin as a symlink (e.g. -> /etc) before
269+
# the root install, a chown that follows it would transfer ownership of
270+
# the link target to them (local privilege escalation). chown -h /
271+
# nofollow is not portable, so refuse the repair entirely when either
272+
# path already exists as a symlink.
273+
if [[ -L "$TARGET_HOME/.local" ]] || [[ -L "$TARGET_HOME/.local/bin" ]]; then
274+
log_warn "Skipping ~/.local ownership repair: $TARGET_HOME/.local or .local/bin is a symlink (refusing to chown through it)"
275+
else
276+
_acfs_repair_mkdir="$(_acfs_system_binary_path mkdir 2>/dev/null || true)"
277+
_acfs_repair_chown="$(_acfs_system_binary_path chown 2>/dev/null || true)"
278+
if [[ -n "$_acfs_repair_mkdir" ]] && [[ -n "$_acfs_repair_chown" ]]; then
279+
if "$_acfs_repair_mkdir" -p "$TARGET_HOME/.local/bin" 2>/dev/null; then
280+
"$_acfs_repair_chown" "${TARGET_USER}" "$TARGET_HOME/.local" "$TARGET_HOME/.local/bin" 2>/dev/null || true
281+
fi
272282
fi
283+
unset _acfs_repair_mkdir _acfs_repair_chown
273284
fi
274-
unset _acfs_repair_mkdir _acfs_repair_chown
275285
fi
276286
fi
277287

scripts/generated/install_cli.sh

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -264,14 +264,24 @@ if [[ "${BASH_SOURCE[0]}" = "${0}" ]]; then
264264
# deliberately non-recursive: only the two directories themselves are
265265
# touched, never their contents.
266266
if [[ $EUID -eq 0 ]] && [[ -n "${TARGET_USER:-}" ]] && [[ "${TARGET_USER}" != "root" ]]; then
267-
_acfs_repair_mkdir="$(_acfs_system_binary_path mkdir 2>/dev/null || true)"
268-
_acfs_repair_chown="$(_acfs_system_binary_path chown 2>/dev/null || true)"
269-
if [[ -n "$_acfs_repair_mkdir" ]] && [[ -n "$_acfs_repair_chown" ]]; then
270-
if "$_acfs_repair_mkdir" -p "$TARGET_HOME/.local/bin" 2>/dev/null; then
271-
"$_acfs_repair_chown" "${TARGET_USER}" "$TARGET_HOME/.local" "$TARGET_HOME/.local/bin" 2>/dev/null || true
267+
# SECURITY: never chown through a symlink. If an untrusted target user
268+
# pre-staged ~/.local or ~/.local/bin as a symlink (e.g. -> /etc) before
269+
# the root install, a chown that follows it would transfer ownership of
270+
# the link target to them (local privilege escalation). chown -h /
271+
# nofollow is not portable, so refuse the repair entirely when either
272+
# path already exists as a symlink.
273+
if [[ -L "$TARGET_HOME/.local" ]] || [[ -L "$TARGET_HOME/.local/bin" ]]; then
274+
log_warn "Skipping ~/.local ownership repair: $TARGET_HOME/.local or .local/bin is a symlink (refusing to chown through it)"
275+
else
276+
_acfs_repair_mkdir="$(_acfs_system_binary_path mkdir 2>/dev/null || true)"
277+
_acfs_repair_chown="$(_acfs_system_binary_path chown 2>/dev/null || true)"
278+
if [[ -n "$_acfs_repair_mkdir" ]] && [[ -n "$_acfs_repair_chown" ]]; then
279+
if "$_acfs_repair_mkdir" -p "$TARGET_HOME/.local/bin" 2>/dev/null; then
280+
"$_acfs_repair_chown" "${TARGET_USER}" "$TARGET_HOME/.local" "$TARGET_HOME/.local/bin" 2>/dev/null || true
281+
fi
272282
fi
283+
unset _acfs_repair_mkdir _acfs_repair_chown
273284
fi
274-
unset _acfs_repair_mkdir _acfs_repair_chown
275285
fi
276286
fi
277287

scripts/generated/install_cloud.sh

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -264,14 +264,24 @@ if [[ "${BASH_SOURCE[0]}" = "${0}" ]]; then
264264
# deliberately non-recursive: only the two directories themselves are
265265
# touched, never their contents.
266266
if [[ $EUID -eq 0 ]] && [[ -n "${TARGET_USER:-}" ]] && [[ "${TARGET_USER}" != "root" ]]; then
267-
_acfs_repair_mkdir="$(_acfs_system_binary_path mkdir 2>/dev/null || true)"
268-
_acfs_repair_chown="$(_acfs_system_binary_path chown 2>/dev/null || true)"
269-
if [[ -n "$_acfs_repair_mkdir" ]] && [[ -n "$_acfs_repair_chown" ]]; then
270-
if "$_acfs_repair_mkdir" -p "$TARGET_HOME/.local/bin" 2>/dev/null; then
271-
"$_acfs_repair_chown" "${TARGET_USER}" "$TARGET_HOME/.local" "$TARGET_HOME/.local/bin" 2>/dev/null || true
267+
# SECURITY: never chown through a symlink. If an untrusted target user
268+
# pre-staged ~/.local or ~/.local/bin as a symlink (e.g. -> /etc) before
269+
# the root install, a chown that follows it would transfer ownership of
270+
# the link target to them (local privilege escalation). chown -h /
271+
# nofollow is not portable, so refuse the repair entirely when either
272+
# path already exists as a symlink.
273+
if [[ -L "$TARGET_HOME/.local" ]] || [[ -L "$TARGET_HOME/.local/bin" ]]; then
274+
log_warn "Skipping ~/.local ownership repair: $TARGET_HOME/.local or .local/bin is a symlink (refusing to chown through it)"
275+
else
276+
_acfs_repair_mkdir="$(_acfs_system_binary_path mkdir 2>/dev/null || true)"
277+
_acfs_repair_chown="$(_acfs_system_binary_path chown 2>/dev/null || true)"
278+
if [[ -n "$_acfs_repair_mkdir" ]] && [[ -n "$_acfs_repair_chown" ]]; then
279+
if "$_acfs_repair_mkdir" -p "$TARGET_HOME/.local/bin" 2>/dev/null; then
280+
"$_acfs_repair_chown" "${TARGET_USER}" "$TARGET_HOME/.local" "$TARGET_HOME/.local/bin" 2>/dev/null || true
281+
fi
272282
fi
283+
unset _acfs_repair_mkdir _acfs_repair_chown
273284
fi
274-
unset _acfs_repair_mkdir _acfs_repair_chown
275285
fi
276286
fi
277287

scripts/generated/install_db.sh

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -264,14 +264,24 @@ if [[ "${BASH_SOURCE[0]}" = "${0}" ]]; then
264264
# deliberately non-recursive: only the two directories themselves are
265265
# touched, never their contents.
266266
if [[ $EUID -eq 0 ]] && [[ -n "${TARGET_USER:-}" ]] && [[ "${TARGET_USER}" != "root" ]]; then
267-
_acfs_repair_mkdir="$(_acfs_system_binary_path mkdir 2>/dev/null || true)"
268-
_acfs_repair_chown="$(_acfs_system_binary_path chown 2>/dev/null || true)"
269-
if [[ -n "$_acfs_repair_mkdir" ]] && [[ -n "$_acfs_repair_chown" ]]; then
270-
if "$_acfs_repair_mkdir" -p "$TARGET_HOME/.local/bin" 2>/dev/null; then
271-
"$_acfs_repair_chown" "${TARGET_USER}" "$TARGET_HOME/.local" "$TARGET_HOME/.local/bin" 2>/dev/null || true
267+
# SECURITY: never chown through a symlink. If an untrusted target user
268+
# pre-staged ~/.local or ~/.local/bin as a symlink (e.g. -> /etc) before
269+
# the root install, a chown that follows it would transfer ownership of
270+
# the link target to them (local privilege escalation). chown -h /
271+
# nofollow is not portable, so refuse the repair entirely when either
272+
# path already exists as a symlink.
273+
if [[ -L "$TARGET_HOME/.local" ]] || [[ -L "$TARGET_HOME/.local/bin" ]]; then
274+
log_warn "Skipping ~/.local ownership repair: $TARGET_HOME/.local or .local/bin is a symlink (refusing to chown through it)"
275+
else
276+
_acfs_repair_mkdir="$(_acfs_system_binary_path mkdir 2>/dev/null || true)"
277+
_acfs_repair_chown="$(_acfs_system_binary_path chown 2>/dev/null || true)"
278+
if [[ -n "$_acfs_repair_mkdir" ]] && [[ -n "$_acfs_repair_chown" ]]; then
279+
if "$_acfs_repair_mkdir" -p "$TARGET_HOME/.local/bin" 2>/dev/null; then
280+
"$_acfs_repair_chown" "${TARGET_USER}" "$TARGET_HOME/.local" "$TARGET_HOME/.local/bin" 2>/dev/null || true
281+
fi
272282
fi
283+
unset _acfs_repair_mkdir _acfs_repair_chown
273284
fi
274-
unset _acfs_repair_mkdir _acfs_repair_chown
275285
fi
276286
fi
277287

0 commit comments

Comments
 (0)