Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
10 changes: 8 additions & 2 deletions cli/testdata/server-config.yaml.golden
Original file line number Diff line number Diff line change
Expand Up @@ -803,8 +803,9 @@ chat:
# opt-in settings.
# (default: false, type: bool)
debugLoggingEnabled: false
# HTTPS URL to receive chat agent lifecycle hook events. Hooks are disabled when
# unset. Requires the agent-lifecycle-hooks experiment.
# HTTPS URL to receive chat agent lifecycle hook events (plain HTTP requires
# --chat-hook-allow-insecure). Hooks are disabled when unset. Requires the
# agent-lifecycle-hooks experiment.
# (default: <unset>, type: url)
hookURL:
# Maximum time to wait for a chat agent lifecycle hook response.
Expand All @@ -814,6 +815,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:[email protected]/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:[email protected]/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:[email protected]/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
31 changes: 25 additions & 6 deletions codersdk/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -4334,7 +4334,7 @@ Write out the current server config as YAML to stdout.`,
},
{
Name: "Chat: Hook URL",
Description: "HTTPS URL to receive chat agent lifecycle hook events. Hooks are disabled when unset. Requires the agent-lifecycle-hooks experiment.",
Description: "HTTPS URL to receive chat agent lifecycle hook events (plain HTTP requires --chat-hook-allow-insecure). Hooks are disabled when unset. Requires the agent-lifecycle-hooks experiment.",
Flag: "chat-hook-url",
Hidden: true,
Env: "CODER_CHAT_HOOK_URL",
Expand Down 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:[email protected]/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