fix!: validate HostnameSuffix and SSHConfigOptions' (#26154)

- Adds server-side and client-side validation for
CODER_CONFIGSSH_HOSTNAME_SUFFIX and CODER_SSH_CONFIG_OPTIONS.
- **Server-side breaking change:** invalid values for either of these will cause `coderd` to exit with an error.
- Client-side: `coder config-ssh` will exit with an error if it detects invalid config.
- Adds tests for the above

Local smoke-testing: ran `develop.sh --env-file <path to an env file
containing badness>`. Validated that server startup failed as expected.

> 🤖 Generated by Coder Agents with supervision from a human.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Cian Johnston
2026-06-10 15:48:02 +01:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 1dc12f8ae7
commit a26c46a3bf
11 changed files with 681 additions and 39 deletions
+4
View File
@@ -578,6 +578,10 @@ func mergeSSHOptions(
) (
sshConfigOptions, error,
) {
if err := coderd.Validate(); err != nil {
return sshConfigOptions{}, xerrors.Errorf("invalid ssh config from coderd: %w", err)
}
// Write agent configuration.
defaultOptions := []string{
"ConnectTimeout=0",
+136
View File
@@ -10,6 +10,8 @@ import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk"
)
func Test_sshConfigSplitOnCoderSection(t *testing.T) {
@@ -302,6 +304,140 @@ func Test_sshConfigExecEscapeSeparatorForce(t *testing.T) {
}
}
func Test_mergeSSHOptions_RejectsUnsafeServerConfig(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
coderd codersdk.SSHConfigResponse
wantErr string
}{
{
name: "HostnameSuffix",
coderd: codersdk.SSHConfigResponse{
HostnameSuffix: "coder\nHost *",
},
wantErr: "workspace hostname suffix",
},
{
name: "HostnamePrefix",
coderd: codersdk.SSHConfigResponse{
HostnamePrefix: "coder.\nHost *",
},
wantErr: "workspace hostname prefix",
},
{
name: "ProxyCommand",
coderd: codersdk.SSHConfigResponse{
SSHConfigOptions: map[string]string{"ProxyCommand": "ssh -W %h:%p bastion"},
},
wantErr: `ssh config option "ProxyCommand" is not allowed`,
},
{
name: "PermitLocalCommand",
coderd: codersdk.SSHConfigResponse{
SSHConfigOptions: map[string]string{"PermitLocalCommand": "yes"},
},
wantErr: `ssh config option "PermitLocalCommand" is not allowed`,
},
{
name: "KnownHostsCommand",
coderd: codersdk.SSHConfigResponse{
SSHConfigOptions: map[string]string{"KnownHostsCommand": "echo key"},
},
wantErr: `ssh config option "KnownHostsCommand" is not allowed`,
},
{
name: "PKCS11Provider",
coderd: codersdk.SSHConfigResponse{
SSHConfigOptions: map[string]string{"PKCS11Provider": "/tmp/evil.so"},
},
wantErr: `ssh config option "PKCS11Provider" is not allowed`,
},
{
name: "NewlineInValue",
coderd: codersdk.SSHConfigResponse{
SSHConfigOptions: map[string]string{"UserKnownHostsFile": "/tmp/known_hosts\nHost *"},
},
wantErr: `ssh config option "UserKnownHostsFile" must not contain carriage return, newline, or NUL characters`,
},
{
name: "SmartcardDevice",
coderd: codersdk.SSHConfigResponse{
SSHConfigOptions: map[string]string{"SmartcardDevice": "/path/to/lib"},
},
wantErr: `not allowed`,
},
{
name: "XAuthLocation",
coderd: codersdk.SSHConfigResponse{
SSHConfigOptions: map[string]string{"XAuthLocation": "/usr/bin/xauth"},
},
wantErr: `not allowed`,
},
{
name: "ProxyJump",
coderd: codersdk.SSHConfigResponse{
SSHConfigOptions: map[string]string{"ProxyJump": "bastion.example.com"},
},
wantErr: `conflicts with`,
},
{
name: "HostnameSuffixGlob",
coderd: codersdk.SSHConfigResponse{
HostnameSuffix: "*",
},
wantErr: `glob`,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := mergeSSHOptions(sshConfigOptions{}, tt.coderd, t.TempDir(), "/tmp/coder")
require.ErrorContains(t, err, tt.wantErr)
})
}
}
func Test_mergeSSHOptions_UserOptionsOverrideServerConfig(t *testing.T) {
t.Parallel()
user := sshConfigOptions{
userHostPrefix: "dev.",
hostnameSuffix: "local",
}
got, err := mergeSSHOptions(user, codersdk.SSHConfigResponse{
HostnamePrefix: "coder.",
HostnameSuffix: "coder",
}, t.TempDir(), "/tmp/coder")
require.NoError(t, err)
require.Equal(t, "dev.", got.userHostPrefix)
require.Equal(t, "local", got.hostnameSuffix)
}
func Test_mergeSSHOptions_AllowsSafeServerConfig(t *testing.T) {
t.Parallel()
got, err := mergeSSHOptions(sshConfigOptions{}, codersdk.SSHConfigResponse{
HostnamePrefix: "coder.",
HostnameSuffix: "coder",
SSHConfigOptions: map[string]string{
"HostName": "example.com",
"User": "coder",
"Port": "22",
"SetEnv": "FOO=bar BAZ=qux",
"UserKnownHostsFile": "/tmp/coder_known_hosts",
},
}, t.TempDir(), "/tmp/coder")
require.NoError(t, err)
require.Equal(t, "coder.", got.userHostPrefix)
require.Equal(t, "coder", got.hostnameSuffix)
require.Contains(t, got.sshOptions, "HostName example.com")
require.Contains(t, got.sshOptions, "SetEnv FOO=bar BAZ=qux")
}
func Test_sshConfigOptions_addOption(t *testing.T) {
t.Parallel()
testCases := []struct {
+57
View File
@@ -168,6 +168,63 @@ func TestConfigSSH(t *testing.T) {
<-copyDone
}
func TestConfigSSH_RejectsUnsafeServerConfig(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("See coder/internal#117")
}
testCases := []struct {
name string
configSSH codersdk.SSHConfigResponse
wantErr string
}{
{
name: "HostnameSuffix",
configSSH: codersdk.SSHConfigResponse{HostnameSuffix: "coder\nHost *"},
wantErr: "workspace hostname suffix",
},
{
name: "HostnamePrefix",
configSSH: codersdk.SSHConfigResponse{HostnamePrefix: "coder.\nHost *"},
wantErr: "workspace hostname prefix",
},
{
name: "HostnameSuffixGlob",
configSSH: codersdk.SSHConfigResponse{HostnameSuffix: "*"},
wantErr: "glob",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
const existingConfig = "Host safe\n\tHostName safe.example.com\n"
client := coderdtest.New(t, &coderdtest.Options{
ConfigSSH: tc.configSSH,
})
_ = coderdtest.CreateFirstUser(t, client)
sshConfigPath := sshConfigFileName(t)
sshConfigFileCreate(t, sshConfigPath, strings.NewReader(existingConfig))
inv, root := clitest.New(t,
"config-ssh",
"--ssh-config-file", sshConfigPath,
"--yes",
)
clitest.SetupConfig(t, client, root)
err := inv.Run()
require.Error(t, err)
require.ErrorContains(t, err, tc.wantErr)
require.Equal(t, existingConfig, sshConfigFileRead(t, sshConfigPath))
})
}
}
func TestConfigSSH_MissingDirectory(t *testing.T) {
t.Parallel()
+17 -22
View File
@@ -431,6 +431,19 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
logger.Debug(ctx, "tracing closed", slog.Error(traceCloseErr))
}()
configSSHOptions, err := vals.SSHConfig.ParseOptions()
if err != nil {
return xerrors.Errorf("parse ssh config options %q: %w", vals.SSHConfig.SSHConfigOptions.String(), err)
}
sshConfigResponse := codersdk.SSHConfigResponse{
HostnamePrefix: vals.SSHConfig.DeploymentName.String(),
HostnameSuffix: vals.WorkspaceHostnameSuffix.String(),
SSHConfigOptions: configSSHOptions,
}
if err := sshConfigResponse.Validate(); err != nil {
return xerrors.Errorf("invalid ssh config: %w", err)
}
httpServers, err := ConfigureHTTPServers(logger, inv, vals)
if err != nil {
return xerrors.Errorf("configure http(s): %w", err)
@@ -641,20 +654,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
return xerrors.Errorf("parse real ip config: %w", err)
}
configSSHOptions, err := vals.SSHConfig.ParseOptions()
if err != nil {
return xerrors.Errorf("parse ssh config options %q: %w", vals.SSHConfig.SSHConfigOptions.String(), err)
}
// The workspace hostname suffix is always interpreted as implicitly beginning with a single dot, so it is
// a config error to explicitly include the dot. This ensures that we always interpret the suffix as a
// separate DNS label, and not just an ordinary string suffix. E.g. a suffix of 'coder' will match
// 'en.coder' but not 'encoder'.
if strings.HasPrefix(vals.WorkspaceHostnameSuffix.String(), ".") {
return xerrors.Errorf("you must omit any leading . in workspace hostname suffix: %s",
vals.WorkspaceHostnameSuffix.String())
}
options := &coderd.Options{
AccessURL: vals.AccessURL.Value(),
AppHostname: appHostname,
@@ -684,14 +683,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
HTTPClient: httpClient,
TemplateScheduleStore: &atomic.Pointer[schedule.TemplateScheduleStore]{},
UserQuietHoursScheduleStore: &atomic.Pointer[schedule.UserQuietHoursScheduleStore]{},
SSHConfig: codersdk.SSHConfigResponse{
HostnamePrefix: vals.SSHConfig.DeploymentName.String(),
SSHConfigOptions: configSSHOptions,
HostnameSuffix: vals.WorkspaceHostnameSuffix.String(),
},
AllowWorkspaceRenames: vals.AllowWorkspaceRenames.Value(),
Entitlements: entitlements.New(),
NotificationsEnqueuer: notifications.NewNoopEnqueuer(), // Changed further down if notifications enabled.
SSHConfig: sshConfigResponse,
AllowWorkspaceRenames: vals.AllowWorkspaceRenames.Value(),
Entitlements: entitlements.New(),
NotificationsEnqueuer: notifications.NewNoopEnqueuer(), // Changed further down if notifications enabled.
}
if httpServers.TLSConfig != nil {
options.TLSCertificates = httpServers.TLSConfig.Certificates
+50
View File
@@ -1823,6 +1823,56 @@ func TestServer(t *testing.T) {
})
}
// TestServer_InvalidSSHDeploymentConfig checks that unsafe SSH config flags are
// rejected at startup, before any database connection, so these invocations
// fail fast.
func TestServer_InvalidSSHDeploymentConfig(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
flag string
wantErr string
}{
{
name: "HostnameSuffixLeadingDot",
flag: "--workspace-hostname-suffix=.coder",
wantErr: "workspace hostname suffix",
},
{
name: "HostnameSuffixNewline",
flag: "--workspace-hostname-suffix=coder\nHost *",
wantErr: "workspace hostname suffix",
},
{
name: "HostnamePrefixNewline",
flag: "--ssh-hostname-prefix=coder.\nHost *",
wantErr: "workspace hostname prefix",
},
{
name: "SSHOptionUnparseable",
flag: "--ssh-config-options=NoSeparatorOption",
wantErr: "parse ssh config options",
},
{
name: "SSHOptionDisallowedKey",
flag: "--ssh-config-options=ProxyCommand=ssh -W %h:%p bastion",
wantErr: `ssh config option "ProxyCommand" is not allowed`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
inv, _ := clitest.New(t, "server", tc.flag)
err := inv.WithContext(ctx).Run()
require.Error(t, err)
require.ErrorContains(t, err, tc.wantErr)
})
}
}
//nolint:tparallel,paralleltest // This test sets environment variables.
func TestServer_ExternalAuthGitHubDefaultProvider(t *testing.T) {
type testCase struct {
+8 -3
View File
@@ -288,8 +288,12 @@ Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI.
--ssh-config-options string-array, $CODER_SSH_CONFIG_OPTIONS
These SSH config options will override the default SSH config options.
Provide options in "key=value" or "key value" format separated by
commas.Using this incorrectly can break SSH to your deployment, use
cautiously.
commas. Using this incorrectly can break SSH to your deployment, use
cautiously. The following options are not allowed: Host, Match,
Include, ProxyCommand, ProxyJump, LocalCommand, PermitLocalCommand,
RemoteCommand, KnownHostsCommand, PKCS11Provider, SecurityKeyProvider,
SmartcardDevice, XAuthLocation. Option values must not contain
newline, carriage return, or NUL characters.
--web-terminal-renderer string, $CODER_WEB_TERMINAL_RENDERER (default: canvas)
The renderer to use when opening a web terminal. Valid values are
@@ -298,7 +302,8 @@ Clients include the Coder CLI, Coder Desktop, IDE extensions, and the web UI.
--workspace-hostname-suffix string, $CODER_WORKSPACE_HOSTNAME_SUFFIX (default: coder)
Workspace hostnames use this suffix in SSH config and Coder Connect on
Coder Desktop. By default it is coder, resulting in names like
myworkspace.coder.
myworkspace.coder. The suffix must not start with a dot, and must not
contain spaces, newlines, or glob characters (* and ?).
CONFIG OPTIONS:
Use a YAML configuration file when your server launch become unwieldy.
+9 -3
View File
@@ -542,12 +542,18 @@ client:
# (default: coder., type: string)
sshHostnamePrefix: coder.
# Workspace hostnames use this suffix in SSH config and Coder Connect on Coder
# Desktop. By default it is coder, resulting in names like myworkspace.coder.
# Desktop. By default it is coder, resulting in names like myworkspace.coder. The
# suffix must not start with a dot, and must not contain spaces, newlines, or glob
# characters (* and ?).
# (default: coder, type: string)
workspaceHostnameSuffix: coder
# These SSH config options will override the default SSH config options. Provide
# options in "key=value" or "key value" format separated by commas.Using this
# incorrectly can break SSH to your deployment, use cautiously.
# options in "key=value" or "key value" format separated by commas. Using this
# incorrectly can break SSH to your deployment, use cautiously. The following
# options are not allowed: Host, Match, Include, ProxyCommand, ProxyJump,
# LocalCommand, PermitLocalCommand, RemoteCommand, KnownHostsCommand,
# PKCS11Provider, SecurityKeyProvider, SmartcardDevice, XAuthLocation. Option
# values must not contain newline, carriage return, or NUL characters.
# (default: <unset>, type: string-array)
sshConfigOptions: []
# The upgrade message to display to users when a client/server mismatch is