mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Copilot Autofix powered by AI
parent
1dc12f8ae7
commit
a26c46a3bf
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user