From 72573e68b090f1c949612616c4175764f3564a0e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:30:35 +0000 Subject: [PATCH 1/5] feat: add --chat-hook-allow-insecure to allow plain HTTP chat hook URLs --- cli/testdata/server-config.yaml.golden | 4 ++ coderd/apidoc/docs.go | 3 ++ coderd/apidoc/swagger.json | 3 ++ coderd/coderd.go | 1 + coderd/x/agenthooks/dispatch/dispatcher.go | 14 +++-- .../dispatch/dispatcher_internal_test.go | 38 ++++++++------ .../x/chatd/chathooks/hooks_internal_test.go | 2 + coderd/x/chatd/hooks_internal_test.go | 1 + coderd/x/chatd/hooks_test.go | 1 + coderd/x/chatd/subagent_internal_test.go | 1 + codersdk/deployment.go | 24 +++++++-- codersdk/deployment_test.go | 52 ++++++++++++++++--- docs/admin/setup/chat-lifecycle-hooks.md | 17 +++--- docs/reference/api/general.md | 1 + docs/reference/api/schemas.md | 5 ++ site/src/api/typesGenerated.ts | 1 + 16 files changed, 131 insertions(+), 37 deletions(-) diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 18d3a89cdc1..898078d7b08 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -814,6 +814,10 @@ 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. Hook traffic carries + # sensitive chat data and signed tokens, so only enable this on a trusted network. + # (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) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9f7da2078ce..4b468ce72d6 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -17407,6 +17407,9 @@ const docTemplate = `{ "debug_logging_enabled": { "type": "boolean" }, + "hook_allow_insecure": { + "type": "boolean" + }, "hook_enabled": { "type": "boolean" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index f3bfe8591d1..976ed4731e2 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -15644,6 +15644,9 @@ "debug_logging_enabled": { "type": "boolean" }, + "hook_allow_insecure": { + "type": "boolean" + }, "hook_enabled": { "type": "boolean" }, diff --git a/coderd/coderd.go b/coderd/coderd.go index 5c430ff8608..825eda504fb 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -898,6 +898,7 @@ func New(options *Options) *API { options.Logger, nil, chatConfig.HookURL.String(), + chatConfig.HookAllowInsecure.Value(), chatConfig.HookSecret.Value(), chatConfig.HookTimeout.Value(), api.DeploymentID, diff --git a/coderd/x/agenthooks/dispatch/dispatcher.go b/coderd/x/agenthooks/dispatch/dispatcher.go index b0c58e0ac57..fce920f7aa2 100644 --- a/coderd/x/agenthooks/dispatch/dispatcher.go +++ b/coderd/x/agenthooks/dispatch/dispatcher.go @@ -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 } @@ -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 } @@ -155,6 +160,7 @@ func New( logger slog.Logger, client *http.Client, hookURL string, + allowInsecureURL bool, secret string, timeout time.Duration, deploymentID string, @@ -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, diff --git a/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go b/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go index 04915f7e402..1ba11e41a68 100644 --- a/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go +++ b/coderd/x/agenthooks/dispatch/dispatcher_internal_test.go @@ -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) { @@ -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) @@ -655,6 +662,7 @@ func newTestDispatcher( testutil.Logger(t), client, hookURL, + false, testSecret, timeout, testDeploymentID, @@ -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"}) @@ -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) diff --git a/coderd/x/chatd/chathooks/hooks_internal_test.go b/coderd/x/chatd/chathooks/hooks_internal_test.go index f30b4751295..df3ca34c188 100644 --- a/coderd/x/chatd/chathooks/hooks_internal_test.go +++ b/coderd/x/chatd/chathooks/hooks_internal_test.go @@ -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", @@ -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", diff --git a/coderd/x/chatd/hooks_internal_test.go b/coderd/x/chatd/hooks_internal_test.go index 501d9a1a4f6..64d821eb823 100644 --- a/coderd/x/chatd/hooks_internal_test.go +++ b/coderd/x/chatd/hooks_internal_test.go @@ -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", diff --git a/coderd/x/chatd/hooks_test.go b/coderd/x/chatd/hooks_test.go index af43976da2d..7b0ada42385 100644 --- a/coderd/x/chatd/hooks_test.go +++ b/coderd/x/chatd/hooks_test.go @@ -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", diff --git a/coderd/x/chatd/subagent_internal_test.go b/coderd/x/chatd/subagent_internal_test.go index 4ab9ab431e9..9f1e55c54fd 100644 --- a/coderd/x/chatd/subagent_internal_test.go +++ b/coderd/x/chatd/subagent_internal_test.go @@ -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", diff --git a/codersdk/deployment.go b/codersdk/deployment.go index dd756a036e8..c657f76b890 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -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. Hook traffic carries sensitive chat data and signed tokens, so only enable this on a trusted network.", + 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.", @@ -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"` @@ -5119,17 +5131,21 @@ 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: + 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") + 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") diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index 83a60cc1bba..a83b9a2baf7 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -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", @@ -821,6 +822,28 @@ 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", @@ -828,6 +851,14 @@ func TestDeploymentValues_Validate_ChatHooks(t *testing.T) { 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: "FragmentURL", url: "https://hooks.example.com/agent#frag", @@ -835,6 +866,14 @@ func TestDeploymentValues_Validate_ChatHooks(t *testing.T) { 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", @@ -892,6 +931,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)) } diff --git a/docs/admin/setup/chat-lifecycle-hooks.md b/docs/admin/setup/chat-lifecycle-hooks.md index 5be326cb4e7..65afdb5ef5f 100644 --- a/docs/admin/setup/chat-lifecycle-hooks.md +++ b/docs/admin/setup/chat-lifecycle-hooks.md @@ -29,12 +29,13 @@ The experiment list is read at startup, so enabling or disabling it requires a ` Set the following deployment options on `coder server`. -| Environment variable | CLI flag | Default | Requirement | -|---------------------------|-----------------------|---------|------------------------------------------------------------------------------------------------------------------------------------------| -| `CODER_CHAT_HOOK_URL` | `--chat-hook-url` | Empty | Use an `https` URL. Hooks are inactive when this value is empty. | -| `CODER_CHAT_HOOK_SECRET` | `--chat-hook-secret` | Empty | Required when the hook URL is set, at least 32 bytes of cryptographically random data. Coder uses this shared secret to sign HS256 JWTs. | -| `CODER_CHAT_HOOK_TIMEOUT` | `--chat-hook-timeout` | `1.5s` | Must be greater than `0` and no more than `5s`. The timeout applies to each request. | -| `CODER_CHAT_HOOK_ENABLED` | `--chat-hook-enabled` | `true` | Set to `false` to stop dispatching without removing the URL or secret. | +| Environment variable | CLI flag | Default | Requirement | +|----------------------------------|------------------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------| +| `CODER_CHAT_HOOK_URL` | `--chat-hook-url` | Empty | Use an `https` URL. Hooks are inactive when this value is empty. | +| `CODER_CHAT_HOOK_SECRET` | `--chat-hook-secret` | Empty | Required when the hook URL is set, at least 32 bytes of cryptographically random data. Coder uses this shared secret to sign HS256 JWTs. | +| `CODER_CHAT_HOOK_TIMEOUT` | `--chat-hook-timeout` | `1.5s` | Must be greater than `0` and no more than `5s`. The timeout applies to each request. | +| `CODER_CHAT_HOOK_ENABLED` | `--chat-hook-enabled` | `true` | Set to `false` to stop dispatching without removing the URL or secret. | +| `CODER_CHAT_HOOK_ALLOW_INSECURE` | `--chat-hook-allow-insecure` | `false` | Set to `true` to allow a plain `http` hook URL. Hook traffic carries sensitive chat data and signed tokens, so only enable this on a trusted network. | Treat `CODER_CHAT_HOOK_ENABLED=false` as the break-glass control. Changing deployment options requires the normal `coder server` configuration rollout for your installation. @@ -42,7 +43,7 @@ Changing deployment options requires the normal `coder server` configuration rol Use a dedicated secret and rotate it through your existing secret-management process. Rotation is a hard cutover: Coder signs with exactly one secret, so dispatches fail until the consumer accepts the new value. Rotate during a maintenance window, or temporarily set `CODER_CHAT_HOOK_ENABLED=false` for the cutover if blocked chats are worse than unreviewed ones for your deployment. -Coder requires the configured URL to use HTTPS. +Coder requires the configured URL to use HTTPS unless `CODER_CHAT_HOOK_ALLOW_INSECURE` is set. A TLS terminator can forward the request to a consumer over plain HTTP on a trusted local network. Configure the consumer with the same `CODER_CHAT_HOOK_URL` value, because that URL is the audience Coder signs into every dispatch. The consumer compares the `aud` claim against its configured audience and rejects a mismatch. @@ -233,7 +234,7 @@ Agent hooks server listening on 127.0.0.1:8081 in log-only mode ``` The reference server accepts optional TLS certificate and key paths. -For local testing with plain HTTP, place an HTTPS reverse proxy in front of it because `CODER_CHAT_HOOK_URL` accepts only HTTPS URLs, and pass the proxy's URL as `--audience`. +For local testing with plain HTTP, either set `CODER_CHAT_HOOK_ALLOW_INSECURE=true` and use the `http` URL directly, or place an HTTPS reverse proxy in front of the consumer and pass the proxy's URL as `--audience`. Run `go run ./scripts/agenthooks-server --help` for all flags and environment variable names. ## Audit dispatches diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 3a8abd793cc..5d972b733c3 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -233,6 +233,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "chat": { "acquire_batch_size": 0, "debug_logging_enabled": true, + "hook_allow_insecure": true, "hook_enabled": true, "hook_secret": "string", "hook_timeout": 0, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index db722b7cc9d..163aebcfc83 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1082,6 +1082,7 @@ "chat": { "acquire_batch_size": 0, "debug_logging_enabled": true, + "hook_allow_insecure": true, "hook_enabled": true, "hook_secret": "string", "hook_timeout": 0, @@ -2455,6 +2456,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in { "acquire_batch_size": 0, "debug_logging_enabled": true, + "hook_allow_insecure": true, "hook_enabled": true, "hook_secret": "string", "hook_timeout": 0, @@ -2480,6 +2482,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in |-------------------------|----------------------------|----------|--------------|-------------| | `acquire_batch_size` | integer | false | | | | `debug_logging_enabled` | boolean | false | | | +| `hook_allow_insecure` | boolean | false | | | | `hook_enabled` | boolean | false | | | | `hook_secret` | string | false | | | | `hook_timeout` | integer | false | | | @@ -5921,6 +5924,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "chat": { "acquire_batch_size": 0, "debug_logging_enabled": true, + "hook_allow_insecure": true, "hook_enabled": true, "hook_secret": "string", "hook_timeout": 0, @@ -6547,6 +6551,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "chat": { "acquire_batch_size": 0, "debug_logging_enabled": true, + "hook_allow_insecure": true, "hook_enabled": true, "hook_secret": "string", "hook_timeout": 0, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 71397825260..e2fe73dd6e8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2037,6 +2037,7 @@ export interface ChatConfig { readonly hook_secret: string; readonly hook_timeout: number; readonly hook_enabled: boolean; + readonly hook_allow_insecure: boolean; /** * @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. From 779e11f65d99a6e14a8574ec9066c66ae7ee0e20 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:05:36 +0000 Subject: [PATCH 2/5] feat: warn that plain HTTP chat hook responses can be forged --- cli/testdata/server-config.yaml.golden | 5 +++-- coderd/coderd.go | 5 +++++ codersdk/deployment.go | 2 +- docs/admin/setup/chat-lifecycle-hooks.md | 15 ++++++++------- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 898078d7b08..0d6143d17e9 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -814,8 +814,9 @@ 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. Hook traffic carries - # sensitive chat data and signed tokens, so only enable this on a trusted network. + # 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 diff --git a/coderd/coderd.go b/coderd/coderd.go index 825eda504fb..afb2c7f0592 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -894,6 +894,11 @@ 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", chatConfig.HookURL.String()), + ) + } hookDispatcher = dispatch.New( options.Logger, nil, diff --git a/codersdk/deployment.go b/codersdk/deployment.go index c657f76b890..23de6943f58 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -4379,7 +4379,7 @@ Write out the current server config as YAML to stdout.`, }, { Name: "Chat: Hook Allow Insecure", - Description: "Allow the chat hook URL to use plain HTTP for any host. Hook traffic carries sensitive chat data and signed tokens, so only enable this on a trusted network.", + 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", diff --git a/docs/admin/setup/chat-lifecycle-hooks.md b/docs/admin/setup/chat-lifecycle-hooks.md index 65afdb5ef5f..e503609abc3 100644 --- a/docs/admin/setup/chat-lifecycle-hooks.md +++ b/docs/admin/setup/chat-lifecycle-hooks.md @@ -29,13 +29,13 @@ The experiment list is read at startup, so enabling or disabling it requires a ` Set the following deployment options on `coder server`. -| Environment variable | CLI flag | Default | Requirement | -|----------------------------------|------------------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------| -| `CODER_CHAT_HOOK_URL` | `--chat-hook-url` | Empty | Use an `https` URL. Hooks are inactive when this value is empty. | -| `CODER_CHAT_HOOK_SECRET` | `--chat-hook-secret` | Empty | Required when the hook URL is set, at least 32 bytes of cryptographically random data. Coder uses this shared secret to sign HS256 JWTs. | -| `CODER_CHAT_HOOK_TIMEOUT` | `--chat-hook-timeout` | `1.5s` | Must be greater than `0` and no more than `5s`. The timeout applies to each request. | -| `CODER_CHAT_HOOK_ENABLED` | `--chat-hook-enabled` | `true` | Set to `false` to stop dispatching without removing the URL or secret. | -| `CODER_CHAT_HOOK_ALLOW_INSECURE` | `--chat-hook-allow-insecure` | `false` | Set to `true` to allow a plain `http` hook URL. Hook traffic carries sensitive chat data and signed tokens, so only enable this on a trusted network. | +| Environment variable | CLI flag | Default | Requirement | +|----------------------------------|------------------------------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `CODER_CHAT_HOOK_URL` | `--chat-hook-url` | Empty | Use an `https` URL. Hooks are inactive when this value is empty. | +| `CODER_CHAT_HOOK_SECRET` | `--chat-hook-secret` | Empty | Required when the hook URL is set, at least 32 bytes of cryptographically random data. Coder uses this shared secret to sign HS256 JWTs. | +| `CODER_CHAT_HOOK_TIMEOUT` | `--chat-hook-timeout` | `1.5s` | Must be greater than `0` and no more than `5s`. The timeout applies to each request. | +| `CODER_CHAT_HOOK_ENABLED` | `--chat-hook-enabled` | `true` | Set to `false` to stop dispatching without removing the URL or secret. | +| `CODER_CHAT_HOOK_ALLOW_INSECURE` | `--chat-hook-allow-insecure` | `false` | Set to `true` to allow a plain `http` hook URL. Plain HTTP lets an attacker on the network forge hook responses, so only enable it on a network you fully trust. | Treat `CODER_CHAT_HOOK_ENABLED=false` as the break-glass control. Changing deployment options requires the normal `coder server` configuration rollout for your installation. @@ -44,6 +44,7 @@ Use a dedicated secret and rotate it through your existing secret-management pro Rotation is a hard cutover: Coder signs with exactly one secret, so dispatches fail until the consumer accepts the new value. Rotate during a maintenance window, or temporarily set `CODER_CHAT_HOOK_ENABLED=false` for the cutover if blocked chats are worse than unreviewed ones for your deployment. Coder requires the configured URL to use HTTPS unless `CODER_CHAT_HOOK_ALLOW_INSECURE` is set. +Plain HTTP removes more than transport privacy: hook responses are what allow or deny tool calls and can rewrite prompts and tool inputs, so anyone on the network path can forge them. Coder logs a warning at startup when hooks run over plain HTTP. A TLS terminator can forward the request to a consumer over plain HTTP on a trusted local network. Configure the consumer with the same `CODER_CHAT_HOOK_URL` value, because that URL is the audience Coder signs into every dispatch. The consumer compares the `aud` claim against its configured audience and rejects a mismatch. From 31b466d84f0e66410375af9865da9d4291c9882d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:24:35 +0000 Subject: [PATCH 3/5] fix(coderd): redact hook URL in startup warning --- coderd/coderd.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index afb2c7f0592..35e80267719 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -896,7 +896,7 @@ 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", chatConfig.HookURL.String()), + slog.F("hook_url", mcpclient.RedactURL(chatConfig.HookURL.String())), ) } hookDispatcher = dispatch.New( From 8126f280960d5b2ed6487c80c5bf349491d8d7a0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:45:59 +0000 Subject: [PATCH 4/5] fix(codersdk): reject hostless chat hook URLs with a port at startup --- codersdk/deployment.go | 5 ++++- codersdk/deployment_test.go | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 23de6943f58..839b54d4fe6 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5138,7 +5138,10 @@ func (c *DeploymentValues) Validate() error { 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 == "" { + // 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, diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go index a83b9a2baf7..de3395aac84 100644 --- a/codersdk/deployment_test.go +++ b/codersdk/deployment_test.go @@ -859,6 +859,21 @@ func TestDeploymentValues_Validate_ChatHooks(t *testing.T) { 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", From 8bf7fec9f1465bd4e5d2f523983da4ed5e4143c5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:01:32 +0000 Subject: [PATCH 5/5] docs: state the insecure escape hatch in the chat hook URL description --- cli/testdata/server-config.yaml.golden | 5 +++-- codersdk/deployment.go | 2 +- docs/admin/setup/chat-lifecycle-hooks.md | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 0d6143d17e9..62e81d8c502 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -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: , type: url) hookURL: # Maximum time to wait for a chat agent lifecycle hook response. diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 839b54d4fe6..92b51dabc56 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -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", diff --git a/docs/admin/setup/chat-lifecycle-hooks.md b/docs/admin/setup/chat-lifecycle-hooks.md index e503609abc3..5bdbf17c6be 100644 --- a/docs/admin/setup/chat-lifecycle-hooks.md +++ b/docs/admin/setup/chat-lifecycle-hooks.md @@ -31,7 +31,7 @@ Set the following deployment options on `coder server`. | Environment variable | CLI flag | Default | Requirement | |----------------------------------|------------------------------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `CODER_CHAT_HOOK_URL` | `--chat-hook-url` | Empty | Use an `https` URL. Hooks are inactive when this value is empty. | +| `CODER_CHAT_HOOK_URL` | `--chat-hook-url` | Empty | Use an `https` URL, or an `http` URL with `CODER_CHAT_HOOK_ALLOW_INSECURE`. Hooks are inactive when this value is empty. | | `CODER_CHAT_HOOK_SECRET` | `--chat-hook-secret` | Empty | Required when the hook URL is set, at least 32 bytes of cryptographically random data. Coder uses this shared secret to sign HS256 JWTs. | | `CODER_CHAT_HOOK_TIMEOUT` | `--chat-hook-timeout` | `1.5s` | Must be greater than `0` and no more than `5s`. The timeout applies to each request. | | `CODER_CHAT_HOOK_ENABLED` | `--chat-hook-enabled` | `true` | Set to `false` to stop dispatching without removing the URL or secret. |