diff --git a/deploy/local/install-linux.sh b/deploy/local/install-linux.sh index 2f849e000..7987e44bf 100755 --- a/deploy/local/install-linux.sh +++ b/deploy/local/install-linux.sh @@ -1128,7 +1128,8 @@ cmd_verify() { fi local tsip - tsip="$(ts ip -4 2>/dev/null | head -1 || true)" + tsip="$(ts ip -4 2>/dev/null || true)" + tsip="${tsip%%$'\n'*}" if [ -n "$tsip" ]; then if http_ok "http://$tsip:$PORT/api/hello" 3; then fail "plaintext port $PORT is reachable on the Tailscale IP $tsip" diff --git a/deploy/local/install-macos.sh b/deploy/local/install-macos.sh index ce893f62f..0d5e903ad 100755 --- a/deploy/local/install-macos.sh +++ b/deploy/local/install-macos.sh @@ -899,7 +899,8 @@ cmd_verify() { fi local tsip - tsip="$(ts ip -4 2>/dev/null | head -1 || true)" + tsip="$(ts ip -4 2>/dev/null || true)" + tsip="${tsip%%$'\n'*}" if [ -n "$tsip" ]; then if curl -s --max-time 3 -o /dev/null "http://$tsip:$PORT/api/hello" 2>/dev/null; then fail "plaintext port $PORT is reachable on the Tailscale IP $tsip" diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index 51867d78b..abb14b771 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -195,7 +195,7 @@ controls. - **FAIL IF** the unset default of `DORMOUSE_BIND_HOST` in `relay/src/config.ts` stops being `undefined` — listen on every interface, what a container wants, where the namespace is the boundary — or if `relay/test/bind-host.test.mjs` stops spawning the real entrypoint to prove the plaintext port is unreachable off-loopback when it *is* set. - **FAIL IF** any installer stops refusing to rewrite a `DORMOUSE_ORIGIN` that no longer matches the node's DNS name. - **FAIL IF** any installer stops refusing to run with elevated privileges — `id -u` on macOS and Linux, the `Administrator` role check on Windows (rationale). -- **FAIL IF** an installer or `manage` names `tailscale funnel` or `AllowFunnel` at all — invoking it, judging its state, or changing it all begin there, and public reachability must exercise the application controls rather than become a forbidden deployment state. Held by `scripts/deploy-lint.mjs` as its one `forbidden` rule (rationale). +- **FAIL IF** an installer or `manage` names `tailscale funnel` or `AllowFunnel` at all — invoking it, judging its state, or changing it all begin there, and public reachability must exercise the application controls rather than become a forbidden deployment state. Held by `scripts/deploy-lint.mjs` (rationale). - **FAIL IF** any decision taken on Tailscale CLI or listener output is reached by piping that output into `grep -q`, or into a `head -1` that exits first; every such search is over text captured first, in a helper as much as inline (rationale). - **FAIL IF** any decision about whether Serve maps `/` to us — the install-time conflict gate, `manage verify`, and the uninstall that turns Serve off — is not additionally scoped to the root line with the port right-bounded. The post-mutation `SERVE_AFTER` assertion is the one deliberate exception (rationale). - **FAIL IF** `scripts/installer-verify-test.mjs` stops driving `has_off_loopback` and `serve_state` over inputs larger than the pipe buffer, or stops pinning `serve_proxies_root`'s root scoping and port bound. `scripts/deploy-lint.mjs` holds that helper's `<<<` pattern and counts its consumers; `serve_root_target` is held by neither on purpose (rationale). diff --git a/scripts/deploy-lint.mjs b/scripts/deploy-lint.mjs index 00ecb24a9..9435dab2d 100644 --- a/scripts/deploy-lint.mjs +++ b/scripts/deploy-lint.mjs @@ -337,6 +337,20 @@ export const RULES = [ Windows: /(?:\btailscale|\bInvoke-Tailscale)\b[^\n]{0,20}funnel|AllowFunnel/i, }, }, + { + // A `head -1` or `grep -q` that exits before the CLI finishes writing gets + // it killed by SIGPIPE, so the decision rides on a race; capture first, + // then search the captured text. `ts ip -4 | head -1` sat in both + // `manage verify`s while every rule above stayed green. + rule: 'Network posture — no Tailscale CLI output is piped into `head` or `grep -q`', + forbidden: true, + violation: 'tsip="$(ts ip -4 2>/dev/null | head -1 || true)"', + patterns: { + macOS: /(?:\btailscale|\bts)\b[^\n|]*\|\s*(?:head\b|grep\s+-\w*q)/, + Linux: /(?:\btailscale|\bts)\b[^\n|]*\|\s*(?:head\b|grep\s+-\w*q)/, + }, + skip: { Windows: 'every Tailscale decision is a `-match` over a string already captured from `Invoke-Tailscale`, so there is no pipeline to take SIGPIPE' }, + }, { // Anchored on the three paths that matter. A bare `chmod 0700` also matches // `run-relay`, `manage` and the probe state dir, and `Protect-Path` has