Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cli/testdata/server-config.yaml.golden
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,11 @@ chat:
# Requires the agent-lifecycle-hooks experiment.
# (default: true, type: bool)
hookEnabled: true
# Allow the chat hook URL to use plain HTTP for any host. Plain HTTP exposes
# sensitive chat data and lets an on-path attacker forge hook responses that
# control agent execution, so only enable this on a network you fully trust.
# (default: false, type: bool)
hookAllowInsecure: false
# Deprecated: AI Gateway routing is now the only routing path. Setting this value
# has no effect. This option will be removed in a future release.
# (default: true, type: bool)
Expand Down
3 changes: 3 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -894,10 +894,16 @@ func New(options *Options) *API {
)
}
if hooksConfigured && hooksExperimentEnabled {
if chatConfig.HookAllowInsecure.Value() && chatConfig.HookURL.Value().Scheme == "http" {
options.Logger.Warn(ctx, "chat hooks use a plain HTTP URL; hook traffic is unencrypted and hook responses controlling agent execution can be forged on the network",
slog.F("hook_url", mcpclient.RedactURL(chatConfig.HookURL.String())),
)
}
hookDispatcher = dispatch.New(
options.Logger,
nil,
chatConfig.HookURL.String(),
chatConfig.HookAllowInsecure.Value(),
chatConfig.HookSecret.Value(),
chatConfig.HookTimeout.Value(),
api.DeploymentID,
Expand Down
14 changes: 10 additions & 4 deletions coderd/x/agenthooks/dispatch/dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,11 @@ type Dispatcher struct {
}

// validateHookURL requires HTTPS because hook traffic carries sensitive data
// and authorization tokens, and responses can control execution. Plain HTTP
// is allowed only for loopback development consumers.
func validateHookURL(raw string) error {
// and authorization tokens, and responses can control execution. Loopback HTTP
// is allowed by default; allowInsecure permits HTTP for any host.
//
//nolint:revive // allowInsecure is operator configuration, not caller control coupling.
func validateHookURL(raw string, allowInsecure bool) error {
if raw == "" {
return nil
}
Expand Down Expand Up @@ -137,6 +139,9 @@ func validateHookURL(raw string) error {
if host == "" {
return xerrors.New("chat hook URL must include a host")
}
if allowInsecure {
return nil
}
if host == "localhost" {
return nil
}
Expand All @@ -155,6 +160,7 @@ func New(
logger slog.Logger,
client *http.Client,
hookURL string,
allowInsecureURL bool,
secret string,
timeout time.Duration,
deploymentID string,
Expand All @@ -174,7 +180,7 @@ func New(
logger: logger.Named("chat_hook_dispatcher"),
client: client,
hookURL: hookURL,
hookURLErr: validateHookURL(hookURL),
hookURLErr: validateHookURL(hookURL, allowInsecureURL),
secret: []byte(secret),
timeout: timeout,
deploymentID: deploymentID,
Expand Down
38 changes: 23 additions & 15 deletions coderd/x/agenthooks/dispatch/dispatcher_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,18 +78,25 @@ func TestDispatcherRejectsCleartextURL(t *testing.T) {
_, _, err := dispatcher.Dispatch(testutil.Context(t, testutil.WaitShort), event)
require.ErrorContains(t, err, "must use HTTPS")

require.NoError(t, validateHookURL(""))
require.NoError(t, validateHookURL("https://hooks.example.com/coder"))
require.NoError(t, validateHookURL("http://localhost:8080/hooks"))
require.NoError(t, validateHookURL("http://127.0.0.1:8080/hooks"))
require.NoError(t, validateHookURL("http://[::1]:8080/hooks"))
require.Error(t, validateHookURL("http://10.0.0.5/hooks"))
require.Error(t, validateHookURL("ftp://hooks.example.com/coder"))
require.ErrorContains(t, validateHookURL("https:///coder"), "must include a host")
require.ErrorContains(t, validateHookURL("https:hooks.example.com"), "must include a host")
require.ErrorContains(t, validateHookURL("http:///hooks"), "must include a host")
require.ErrorContains(t, validateHookURL("https://hooks.example.com/coder#frag"), "must not contain a fragment")
require.ErrorContains(t, validateHookURL("https://user:pass@hooks.example.com/coder"), "must not contain userinfo")
require.NoError(t, validateHookURL("", false))
require.NoError(t, validateHookURL("https://hooks.example.com/coder", false))
require.NoError(t, validateHookURL("http://localhost:8080/hooks", false))
require.NoError(t, validateHookURL("http://127.0.0.1:8080/hooks", false))
require.NoError(t, validateHookURL("http://[::1]:8080/hooks", false))
require.Error(t, validateHookURL("http://10.0.0.5/hooks", false))
require.Error(t, validateHookURL("ftp://hooks.example.com/coder", false))
require.ErrorContains(t, validateHookURL("https:///coder", false), "must include a host")
require.ErrorContains(t, validateHookURL("https:hooks.example.com", false), "must include a host")
require.ErrorContains(t, validateHookURL("http:///hooks", false), "must include a host")
require.ErrorContains(t, validateHookURL("https://hooks.example.com/coder#frag", false), "must not contain a fragment")
require.ErrorContains(t, validateHookURL("https://user:pass@hooks.example.com/coder", false), "must not contain userinfo")

require.NoError(t, validateHookURL("http://10.0.0.5/hooks", true))
require.NoError(t, validateHookURL("http://hooks.example.com/coder", true))
require.Error(t, validateHookURL("ftp://hooks.example.com/coder", true))
require.ErrorContains(t, validateHookURL("http:///hooks", true), "must include a host")
require.ErrorContains(t, validateHookURL("http://hooks.example.com/coder#frag", true), "must not contain a fragment")
require.ErrorContains(t, validateHookURL("http://user:pass@hooks.example.com/coder", true), "must not contain userinfo")
}

func TestDispatcherDeny(t *testing.T) {
Expand Down Expand Up @@ -566,7 +573,7 @@ func TestDispatcherRejectedResponseIsNotObserved(t *testing.T) {

registry := prometheus.NewRegistry()
dispatcher := New(
testutil.Logger(t), server.Client(), server.URL, testSecret, time.Second,
testutil.Logger(t), server.Client(), server.URL, false, testSecret, time.Second,
testDeploymentID, testVersion, registry,
)
_, _, err := dispatcher.Dispatch(testutil.Context(t, testutil.WaitLong), event)
Expand Down Expand Up @@ -655,6 +662,7 @@ func newTestDispatcher(
testutil.Logger(t),
client,
hookURL,
false,
testSecret,
timeout,
testDeploymentID,
Expand Down Expand Up @@ -738,7 +746,7 @@ func TestDispatcherAdmissionReserve(t *testing.T) {
t.Cleanup(server.Close)

dispatcher := New(
testutil.Logger(t), server.Client(), server.URL, testSecret, testutil.WaitShort,
testutil.Logger(t), server.Client(), server.URL, false, testSecret, testutil.WaitShort,
testDeploymentID, testVersion, prometheus.NewRegistry(),
)
event := newTestEvent(t, agenthooks.EventUserPromptSubmit, agenthooks.UserPromptSubmitData{Prompt: "hi"})
Expand Down Expand Up @@ -797,7 +805,7 @@ func TestDispatcherAdmissionReserve(t *testing.T) {
t.Cleanup(server.Close)

dispatcher := New(
testutil.Logger(t), server.Client(), server.URL, testSecret, testutil.WaitShort,
testutil.Logger(t), server.Client(), server.URL, false, testSecret, testutil.WaitShort,
testDeploymentID, testVersion, prometheus.NewRegistry(),
)
fill(t, dispatcher.admission, maxAdmissionDispatches)
Expand Down
2 changes: 2 additions & 0 deletions coderd/x/chatd/chathooks/hooks_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func TestSessionStartDispatchSources(t *testing.T) {
slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
consumer.Client(),
consumer.URL,
false,
secret,
time.Second,
"test-deployment",
Expand Down Expand Up @@ -102,6 +103,7 @@ func newTestTrigger(t *testing.T, handler http.Handler) *Trigger {
slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
consumer.Client(),
consumer.URL,
false,
"test-hook-secret-32-bytes-minimum!!",
time.Second,
"test-deployment",
Expand Down
1 change: 1 addition & 0 deletions coderd/x/chatd/hooks_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ func TestSessionStartDispatchFailureFinishesGeneration(t *testing.T) {
slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
consumer.Client(),
consumer.URL,
false,
"test-hook-secret-32-bytes-minimum!!",
time.Second,
"test-deployment",
Expand Down
1 change: 1 addition & 0 deletions coderd/x/chatd/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ func newHookDispatcher(t *testing.T, _ database.Store, consumer *httptest.Server
slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
consumer.Client(),
consumer.URL,
false,
"test-hook-secret-32-bytes-minimum!!",
time.Second,
"test-deployment",
Expand Down
1 change: 1 addition & 0 deletions coderd/x/chatd/subagent_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ func TestCreateChildSubagentChatDispatchesUserPromptSubmit(t *testing.T) {
slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
consumer.Client(),
consumer.URL,
false,
"test-hook-secret-32-bytes-minimum!!",
time.Second,
"test-deployment",
Expand Down
29 changes: 24 additions & 5 deletions codersdk/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -4377,6 +4377,17 @@ Write out the current server config as YAML to stdout.`,
Group: &deploymentGroupChat,
YAML: "hookEnabled",
},
{
Name: "Chat: Hook Allow Insecure",
Description: "Allow the chat hook URL to use plain HTTP for any host. Plain HTTP exposes sensitive chat data and lets an on-path attacker forge hook responses that control agent execution, so only enable this on a network you fully trust.",
Flag: "chat-hook-allow-insecure",
Hidden: true,
Env: "CODER_CHAT_HOOK_ALLOW_INSECURE",
Value: &c.AI.Chat.HookAllowInsecure,
Default: "false",
Group: &deploymentGroupChat,
YAML: "hookAllowInsecure",
},
{
Name: "Chat: AI Gateway Routing Enabled",
Description: "Deprecated: AI Gateway routing is now the only routing path. Setting this value has no effect. This option will be removed in a future release.",
Expand Down Expand Up @@ -5065,6 +5076,7 @@ type ChatConfig struct {
HookSecret serpent.String `json:"hook_secret" typescript:",notnull"`
HookTimeout serpent.Duration `json:"hook_timeout" typescript:",notnull"`
HookEnabled serpent.Bool `json:"hook_enabled" typescript:",notnull"`
HookAllowInsecure serpent.Bool `json:"hook_allow_insecure" typescript:",notnull"`
// Deprecated: AI Gateway routing is now the only routing path. Setting this
// value has no effect. This option will be removed in a future release.
AIGatewayRoutingEnabled serpent.Bool `json:"ai_gateway_routing_enabled" typescript:",notnull" swaggerignore:"true"`
Expand Down Expand Up @@ -5119,17 +5131,24 @@ func (c *DeploymentValues) Validate() error {
if c.AI.Chat.HookEnabled.Value() {
if c.AI.Chat.HookURL.String() != "" {
hookURL := c.AI.Chat.HookURL.Value()
if hookURL.Scheme != "https" {
return xerrors.New("chat hook URL must use HTTPS; set --chat-hook-url to an HTTPS URL")
allowInsecure := c.AI.Chat.HookAllowInsecure.Value()
switch {
case hookURL.Scheme == "https":
case hookURL.Scheme == "http" && allowInsecure:
Comment thread
ibetitsmike marked this conversation as resolved.
default:
return xerrors.New("chat hook URL must use HTTPS; set --chat-hook-url to an HTTPS URL, or set --chat-hook-allow-insecure to allow plain HTTP")
}
if hookURL.Host == "" {
return xerrors.New("chat hook URL must include a host; set --chat-hook-url to a complete HTTPS URL")
// Hostname() instead of Host: a URL like http://:8080/hooks has a
// non-empty Host (":8080") but no hostname, and the dispatcher
// rejects it on every dispatch.
if hookURL.Hostname() == "" {
return xerrors.New("chat hook URL must include a host; set --chat-hook-url to a complete URL")
}
// The configured string is signed verbatim as the JWT audience,
// and neither component is ever transmitted, so a consumer
// configured with the URL it actually serves would never match.
if hookURL.Fragment != "" || hookURL.RawFragment != "" || hookURL.User != nil {
return xerrors.New("chat hook URL must not contain a fragment or userinfo; set --chat-hook-url to a plain HTTPS URL")
return xerrors.New("chat hook URL must not contain a fragment or userinfo; set --chat-hook-url to a URL without a fragment or userinfo")
}
if c.AI.Chat.HookSecret.Value() == "" {
return xerrors.New("chat hook secret is required when chat hook URL is set; set --chat-hook-secret")
Expand Down
67 changes: 61 additions & 6 deletions codersdk/deployment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -791,12 +791,13 @@ func TestDeploymentValues_Validate_ChatHooks(t *testing.T) {
t.Parallel()

tests := []struct {
name string
disabled bool
url string
secret string
timeout time.Duration
wantErr string
name string
disabled bool
url string
secret string
timeout time.Duration
allowInsecure bool
wantErr string
}{
{
name: "NoURL",
Expand All @@ -821,20 +822,73 @@ func TestDeploymentValues_Validate_ChatHooks(t *testing.T) {
timeout: 1500 * time.Millisecond,
wantErr: "chat hook URL must use HTTPS",
},
{
name: "HTTPURLAllowInsecure",
url: "http://hooks.example.com/agent",
secret: "0123456789abcdef0123456789abcdef",
timeout: 1500 * time.Millisecond,
allowInsecure: true,
},
{
name: "NonHTTPSchemeAllowInsecure",
url: "ftp://hooks.example.com/agent",
secret: "0123456789abcdef0123456789abcdef",
timeout: 1500 * time.Millisecond,
allowInsecure: true,
wantErr: "chat hook URL must use HTTPS",
},
{
name: "AllowInsecureStillRequiresSecret",
url: "http://hooks.example.com/agent",
timeout: 1500 * time.Millisecond,
allowInsecure: true,
wantErr: "chat hook secret is required",
},
{
name: "HostlessURL",
url: "https:///hook",
secret: "0123456789abcdef0123456789abcdef",
timeout: 1500 * time.Millisecond,
wantErr: "must include a host",
},
{
name: "HostlessHTTPURLAllowInsecure",
url: "http:///hook",
secret: "0123456789abcdef0123456789abcdef",
timeout: 1500 * time.Millisecond,
allowInsecure: true,
wantErr: "set --chat-hook-url to a complete URL",
},
{
name: "PortOnlyHTTPURLAllowInsecure",
url: "http://:8080/hooks",
secret: "0123456789abcdef0123456789abcdef",
timeout: 1500 * time.Millisecond,
allowInsecure: true,
wantErr: "must include a host",
},
{
name: "PortOnlyHTTPSURL",
url: "https://:8080/hooks",
secret: "0123456789abcdef0123456789abcdef",
timeout: 1500 * time.Millisecond,
wantErr: "must include a host",
},
{
name: "FragmentURL",
url: "https://hooks.example.com/agent#frag",
secret: "0123456789abcdef0123456789abcdef",
timeout: 1500 * time.Millisecond,
wantErr: "must not contain a fragment or userinfo",
},
{
name: "FragmentHTTPURLAllowInsecure",
url: "http://hooks.example.com/agent#frag",
secret: "0123456789abcdef0123456789abcdef",
timeout: 1500 * time.Millisecond,
allowInsecure: true,
wantErr: "set --chat-hook-url to a URL without a fragment or userinfo",
},
{
name: "UserinfoURL",
url: "https://user:pass@hooks.example.com/agent",
Expand Down Expand Up @@ -892,6 +946,7 @@ func TestDeploymentValues_Validate_ChatHooks(t *testing.T) {
dv.AI.Chat.HookEnabled = serpent.Bool(!tt.disabled)
dv.AI.Chat.HookSecret = serpent.String(tt.secret)
dv.AI.Chat.HookTimeout = serpent.Duration(tt.timeout)
dv.AI.Chat.HookAllowInsecure = serpent.Bool(tt.allowInsecure)
if tt.url != "" {
require.NoError(t, dv.AI.Chat.HookURL.Set(tt.url))
}
Expand Down
Loading
Loading