Skip to content

feat: implement HTTP allowed hosts/origins checking #49

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
55 changes: 51 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ Control [Claude Code](https://github.com/anthropics/claude-code), [Goose](https:

![agentapi-chat](https://github.com/user-attachments/assets/57032c9f-4146-4b66-b219-09e38ab7690d)


You can use AgentAPI:

- to build a unified chat interface for coding agents
Expand Down Expand Up @@ -54,9 +53,6 @@ You can use AgentAPI:

Run an HTTP server that lets you control an agent. If you'd like to start an agent with additional arguments, pass the full agent command after the `--` flag.

> [!NOTE]
> When using Codex, always specify the agent type explicitly (`agentapi server --type=codex -- codex`), or message formatting may break.

```bash
agentapi server -- claude --allowedTools "Bash(git*) Edit Replace"
```
Expand All @@ -68,6 +64,9 @@ agentapi server -- aider --model sonnet --api-key anthropic=sk-ant-apio3-XXX
agentapi server -- goose
```

> [!NOTE]
> When using Codex, always specify the agent type explicitly (`agentapi server --type=codex -- codex`), or message formatting may break.

An OpenAPI schema is available in [openapi.json](openapi.json).

By default, the server runs on port 3284. Additionally, the server exposes the same OpenAPI schema at http://localhost:3284/openapi.json and the available endpoints in a documentation UI at http://localhost:3284/docs.
Expand All @@ -79,6 +78,54 @@ There are 4 endpoints:
- GET `/status` - returns the current status of the agent, either "stable" or "running"
- GET `/events` - an SSE stream of events from the agent: message and status updates

#### Allowed hosts

By default, the server only allows requests with the host header set to `localhost`. If you'd like to host AgentAPI elsewhere, you can change this by using the `AGENTAPI_ALLOWED_HOSTS` environment variable or the `--allowed-hosts` flag. Hosts must be hostnames only (no ports); the server ignores the port portion of incoming requests when authorizing.

To allow requests from any host, use `*` as the allowed host.

```bash
agentapi server --allowed-hosts '*' -- claude
```

To allow a specific host, use:

```bash
agentapi server --allowed-hosts 'example.com' -- claude
```

To specify multiple hosts, use a comma-separated list when using the `--allowed-hosts` flag, or a space-separated list when using the `AGENTAPI_ALLOWED_HOSTS` environment variable.

```bash
agentapi server --allowed-hosts 'example.com,example.org' -- claude
# or
AGENTAPI_ALLOWED_HOSTS='example.com example.org' agentapi server -- claude
```

#### Allowed origins

By default, the server allows CORS requests from `http://localhost:3284`, `http://localhost:3000`, and `http://localhost:3001`. If you'd like to change which origins can make cross-origin requests to AgentAPI, you can change this by using the `AGENTAPI_ALLOWED_ORIGINS` environment variable or the `--allowed-origins` flag.

To allow requests from any origin, use `*` as the allowed origin:

```bash
agentapi server --allowed-origins '*' -- claude
```

To allow a specific origin, use:

```bash
agentapi server --allowed-origins 'https://example.com' -- claude
```

To specify multiple origins, use a comma-separated list when using the `--allowed-origins` flag, or a space-separated list when using the `AGENTAPI_ALLOWED_ORIGINS` environment variable. Origins must include the protocol (`http://` or `https://`) and support wildcards (e.g., `https://*.example.com`):

```bash
agentapi server --allowed-origins 'https://example.com,http://localhost:3000' -- claude
# or
AGENTAPI_ALLOWED_ORIGINS='https://example.com http://localhost:3000' agentapi server -- claude
```

### `agentapi attach`

Attach to a running agent's terminal session.
Expand Down
134 changes: 123 additions & 11 deletions cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import (
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"sort"
"strings"
"unicode"

"github.com/spf13/cobra"
"github.com/spf13/viper"
Expand Down Expand Up @@ -58,6 +60,79 @@ func parseAgentType(firstArg string, agentTypeVar string) (AgentType, error) {
return AgentTypeCustom, nil
}

// Validate allowed hosts don't contain whitespace, commas, schemes, or ports.
// Viper/Cobra use different separators (space for env vars, comma for flags),
// so these characters likely indicate user error.
func validateAllowedHosts(input []string) error {
if len(input) == 0 {
return fmt.Errorf("the list must not be empty")
}
// First pass: whitespace & comma checks (surface these errors first)
for _, item := range input {
for _, r := range item {
if unicode.IsSpace(r) {
return fmt.Errorf("'%s' contains whitespace characters, which are not allowed", item)
}
}
if strings.Contains(item, ",") {
return fmt.Errorf("'%s' contains comma characters, which are not allowed", item)
}
}
// Second pass: scheme check
for _, item := range input {
if strings.Contains(item, "http://") || strings.Contains(item, "https://") {
return fmt.Errorf("'%s' must not include http:// or https://", item)
}
}
// Third pass: port check (but allow IPv6 literals without ports)
for _, item := range input {
trimmed := strings.TrimSpace(item)
colonCount := strings.Count(trimmed, ":")
// If bracketed, rely on url.Parse to detect a port in "]:<port>" form.
if strings.HasPrefix(trimmed, "[") {
if u, err := url.Parse("http://" + trimmed); err == nil {
if u.Port() != "" {
return fmt.Errorf("'%s' must not include a port", item)
}
}
continue
}
// Unbracketed IPv6: multiple colons and no brackets; treat as valid (no ports allowed here)
if colonCount >= 2 {
continue
}
// IPv4 or hostname: if URL parsing finds a port or there's a single colon, it's invalid
if u, err := url.Parse("http://" + trimmed); err == nil {
if u.Port() != "" {
return fmt.Errorf("'%s' must not include a port", item)
}
}
if colonCount == 1 {
return fmt.Errorf("'%s' must not include a port", item)
}
}
return nil
}

// Validate allowed origins don't contain whitespace or commas.
// Origins must include a scheme, validated later by the HTTP layer.
func validateAllowedOrigins(input []string) error {
if len(input) == 0 {
return fmt.Errorf("the list must not be empty")
}
for _, item := range input {
for _, r := range item {
if unicode.IsSpace(r) {
return fmt.Errorf("'%s' contains whitespace characters, which are not allowed", item)
}
}
if strings.Contains(item, ",") {
return fmt.Errorf("'%s' contains comma characters, which are not allowed", item)
}
}
return nil
}

func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) error {
agent := argsToPass[0]
agentTypeValue := viper.GetString(FlagType)
Expand Down Expand Up @@ -95,12 +170,17 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
}
}
port := viper.GetInt(FlagPort)
srv := httpapi.NewServer(ctx, httpapi.ServerConfig{
AgentType: agentType,
Process: process,
Port: port,
ChatBasePath: viper.GetString(FlagChatBasePath),
srv, err := httpapi.NewServer(ctx, httpapi.ServerConfig{
AgentType: agentType,
Process: process,
Port: port,
ChatBasePath: viper.GetString(FlagChatBasePath),
AllowedHosts: viper.GetStringSlice(FlagAllowedHosts),
AllowedOrigins: viper.GetStringSlice(FlagAllowedOrigins),
})
if err != nil {
return xerrors.Errorf("failed to create server: %w", err)
}
if printOpenAPI {
fmt.Println(srv.GetOpenAPI())
return nil
Expand Down Expand Up @@ -150,12 +230,15 @@ type flagSpec struct {
}

const (
FlagType = "type"
FlagPort = "port"
FlagPrintOpenAPI = "print-openapi"
FlagChatBasePath = "chat-base-path"
FlagTermWidth = "term-width"
FlagTermHeight = "term-height"
FlagType = "type"
FlagPort = "port"
FlagPrintOpenAPI = "print-openapi"
FlagChatBasePath = "chat-base-path"
FlagTermWidth = "term-width"
FlagTermHeight = "term-height"
FlagAllowedHosts = "allowed-hosts"
FlagAllowedOrigins = "allowed-origins"
FlagExit = "exit"
)

func CreateServerCmd() *cobra.Command {
Expand All @@ -164,7 +247,22 @@ func CreateServerCmd() *cobra.Command {
Short: "Run the server",
Long: fmt.Sprintf("Run the server with the specified agent (one of: %s)", strings.Join(agentNames, ", ")),
Args: cobra.MinimumNArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
allowedHosts := viper.GetStringSlice(FlagAllowedHosts)
if err := validateAllowedHosts(allowedHosts); err != nil {
return xerrors.Errorf("failed to validate allowed hosts: %w", err)
}
allowedOrigins := viper.GetStringSlice(FlagAllowedOrigins)
if err := validateAllowedOrigins(allowedOrigins); err != nil {
return xerrors.Errorf("failed to validate allowed origins: %w", err)
}
return nil
},
Run: func(cmd *cobra.Command, args []string) {
// The --exit flag is used for testing validation of flags in the test suite
if viper.GetBool(FlagExit) {
return
}
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
ctx := logctx.WithLogger(context.Background(), logger)
if err := runServer(ctx, logger, cmd.Flags().Args()); err != nil {
Expand All @@ -181,6 +279,10 @@ func CreateServerCmd() *cobra.Command {
{FlagChatBasePath, "c", "/chat", "Base path for assets and routes used in the static files of the chat interface", "string"},
{FlagTermWidth, "W", uint16(80), "Width of the emulated terminal", "uint16"},
{FlagTermHeight, "H", uint16(1000), "Height of the emulated terminal", "uint16"},
// localhost is the default host for the server. Port is ignored during matching.
{FlagAllowedHosts, "a", []string{"localhost"}, "HTTP allowed hosts (hostnames only, no ports). Use '*' for all, comma-separated list via flag, space-separated list via AGENTAPI_ALLOWED_HOSTS env var", "stringSlice"},
// localhost:3284 is the default origin when you open the chat interface in your browser. localhost:3000 and 3001 are used during development.
{FlagAllowedOrigins, "o", []string{"http://localhost:3284", "http://localhost:3000", "http://localhost:3001"}, "HTTP allowed origins. Use '*' for all, comma-separated list via flag, space-separated list via AGENTAPI_ALLOWED_ORIGINS env var", "stringSlice"},
}

for _, spec := range flagSpecs {
Expand All @@ -193,6 +295,8 @@ func CreateServerCmd() *cobra.Command {
serverCmd.Flags().BoolP(spec.name, spec.shorthand, spec.defaultValue.(bool), spec.usage)
case "uint16":
serverCmd.Flags().Uint16P(spec.name, spec.shorthand, spec.defaultValue.(uint16), spec.usage)
case "stringSlice":
serverCmd.Flags().StringSliceP(spec.name, spec.shorthand, spec.defaultValue.([]string), spec.usage)
default:
panic(fmt.Sprintf("unknown flag type: %s", spec.flagType))
}
Expand All @@ -201,6 +305,14 @@ func CreateServerCmd() *cobra.Command {
}
}

serverCmd.Flags().Bool(FlagExit, false, "Exit immediately after parsing arguments")
if err := serverCmd.Flags().MarkHidden(FlagExit); err != nil {
panic(fmt.Sprintf("failed to mark flag %s as hidden: %v", FlagExit, err))
}
if err := viper.BindPFlag(FlagExit, serverCmd.Flags().Lookup(FlagExit)); err != nil {
panic(fmt.Sprintf("failed to bind flag %s: %v", FlagExit, err))
}

viper.SetEnvPrefix("AGENTAPI")
viper.AutomaticEnv()
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
Expand Down
Loading