policy: pin SSH forwarding behavior and recommend upgrades

This commit is contained in:
Kristoffer Dalby committed 2026-10-29 12:43:14 +01:00
1 parent bd1876e1fa
commit 998c4b0619
11 files changed
+15977 -1

No files matched your search

+1 -1
View File
@@ -29,7 +29,7 @@ config*.yaml
!config-example.yaml
derp.yaml
*.hujson
!hscontrol/policy/v2/testdata/*/*.hujson
!hscontrol/policy/v2/testdata/**/*.hujson
*.key
/db.sqlite
*.sqlite3
+16
View File
@@ -150,6 +150,22 @@ the ACME DNS-01 challenge through a built-in DNS provider: `acmedns`,
- Deleting an ephemeral node no longer logs a spurious `ephemeral node deletion failed` error later [#3575](https://github.com/juanfont/headscale/pull/3575)
- Add the v2 `acl/validate` endpoint, which Terraform provider 0.29.3 and newer call to check `tailscale_acl` at plan time; before it, those releases failed with `invalid character 'p' after top-level value`
## 0.29.7 (202x-xx-xx)
### Security
We recommend that all users upgrade to Headscale 0.29.7 and Tailscale v1.104.4 or newer,
especially those using Tailscale SSH. Tailscale v1.104.4 fixes vulnerabilities in SSH
forwarding, SSH session handling and Serve. These fixes run in `tailscaled` on your
nodes; upgrading Headscale alone does not apply them. See Tailscale's
[security bulletins](https://tailscale.com/security-bulletins#ts-2026-014) and
[Tailscale SSH: Findings and fixes](https://tailscale.com/blog/tailscale-ssh-findings-fixes).
Headscale follows Tailscale's SSH policy behavior: forwarding is allowed by default,
with no per-rule forwarding controls. As an interim mitigation for SSH forwarding
vulnerabilities, set `TS_SSH_DISABLE_FORWARDING=1` in the destination `tailscaled`
service environment and restart the service, as described in the security bulletin.
## 0.29.6 (2026-10-24)
**Minimum supported Tailscale client version: v1.80.0**
@@ -58,6 +58,68 @@ func loadSSHTestFile(t *testing.T, path string) *testcapture.Capture {
return c
}
// TestSSHForwardingDataCompat pins upstream forwarding actions and rejects
// unsupported policy fields. The complete captures retain SSHUsers, but its new
// numeric-user deny entry needs separate user-mapping compatibility coverage.
func TestSSHForwardingDataCompat(t *testing.T) {
t.Parallel()
files, err := filepath.Glob(filepath.Join("testdata", "ssh_results", "forwarding", "ssh-*.hujson"))
require.NoError(t, err)
require.NotEmpty(t, files)
users := setupSSHDataCompatUsers()
for _, file := range files {
tf := loadSSHTestFile(t, file)
t.Run(tf.TestID, func(t *testing.T) {
t.Parallel()
require.Empty(t, tf.CaptureError)
require.NotEmpty(t, tf.Captures)
pol, err := unmarshalPolicy([]byte(tf.Input.FullPolicy))
if tf.Input.APIResponseCode != 200 {
require.Equal(t, 400, tf.Input.APIResponseCode)
require.NotNil(t, tf.Input.APIResponseBody)
field, ok := strings.CutPrefix(tf.Input.APIResponseBody.Message, "json: unknown field ")
require.True(t, ok, "expected an upstream unknown-field rejection")
require.ErrorIs(t, err, ErrUnknownField)
require.ErrorContains(t, err, fmt.Sprintf("%s: %s", ErrUnknownField, field))
return
}
require.NoError(t, err)
nodes := buildGrantsNodesFromCapture(users, tf)
for nodeName, capture := range tf.Captures {
t.Run(nodeName, func(t *testing.T) {
node := findNodeByGivenName(nodes, nodeName)
require.NotNil(t, node)
got, err := pol.compileSSHPolicy("https://unused", users, node.View(), nodes.ViewSlice())
require.NoError(t, err)
var rules []*tailcfg.SSHRule
if got != nil {
rules = got.Rules
}
require.Len(t, rules, len(capture.SSHRules))
for i, want := range capture.SSHRules {
require.NotNil(t, want.Action)
assert.Equal(t, want.Action, rules[i].Action)
}
})
}
})
}
}
// sshSkipReasons documents captures the upstream control plane accepts
// but headscale cannot yet represent. Each entry names the feature gap.
var sshSkipReasons = map[string]string{
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff