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
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ docker compose up -d
go test ./...
```

> No test files exist in the repository yet — this is the standard command to run once tests are added. See `CONTRIBUTING.md` before submitting a PR that adds functionality without tests.
> Tests live beside the code they cover. See `CONTRIBUTING.md` before submitting a PR that adds functionality without tests.

### Configuration

Expand All @@ -203,6 +203,54 @@ Edit `config.yaml` or use environment variables:
| Max payload | `messages.max_payload_size` | `RELAY_MESSAGES_MAX_PAYLOAD_SIZE` | `524288` (500KB) |
| Rate limit | `security.rate_limit` | `RELAY_SECURITY_RATE_LIMIT` | `30` req/min/IP |
| API key | `security.api_key` | `RELAY_SECURITY_API_KEY` | `` (disabled) |
| CORS origins | `security.allowed_origins` | `RELAY_SECURITY_ALLOWED_ORIGINS` | `` (CORS disabled) |

### CORS
Comment thread
coderabbitai[bot] marked this conversation as resolved.

By default the relay serves **no CORS headers**, so a browser calling it from
another origin is blocked at the preflight. There are two ways to run a
browser client:

**1. Reverse proxy (no relay configuration).** Put the relay behind a
same-origin path in your own app — a Vercel rewrite, an nginx `location`, a
Vite `server.proxy` entry. The request is never cross-origin, so CORS never
applies. This is the setup the ThruBox client docs assume.

**2. Direct calls with an origin allowlist.** List the origins you want to
serve and browsers can call the relay directly, no proxy needed:

```yaml
security:
allowed_origins:
- "https://app.example.com"
- "http://localhost:5173"
```

or via the environment, comma-separated:

```bash
RELAY_SECURITY_ALLOWED_ORIGINS="https://app.example.com,http://localhost:5173"
```

When an origin is allowed, the relay answers preflights and returns
`Access-Control-Allow-Origin` for that origin, `Access-Control-Allow-Methods:
GET, POST, DELETE, OPTIONS`, and `Access-Control-Allow-Headers: Content-Type`
— plus `X-API-Key` when `security.api_key` is set.

Notes:

- Entries must be full origins (`https://host[:port]`), with no path. A bare
hostname or a URL with a path is rejected at startup rather than silently
never matching.
- `"*"` allows any origin. It cannot be combined with specific origins, and it
is a poor fit for a relay with no API key configured — anything on the web
can then read and write messages from a browser.
- Credentialed CORS is not supported. The relay authenticates with the
`X-API-Key` header, not cookies, so `Access-Control-Allow-Credentials` is
never sent.
- An allowlisted origin still has to satisfy `security.api_key` and the rate
limiter. CORS controls which origins a browser will let read a response; it
is not authentication.

---

Expand Down
14 changes: 13 additions & 1 deletion cmd/relay/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func main() {
"storage_path", cfg.Storage.Path,
"ttl_days", cfg.Messages.TTLDays,
"rate_limit", cfg.Security.RateLimit,
"allowed_origins", cfg.Security.AllowedOrigins,
)

// Initialize storage
Expand Down Expand Up @@ -70,13 +71,24 @@ func main() {
mux.HandleFunc("GET /api/messages/{address}", msgHandler.HandleGetByAddress)
mux.HandleFunc("DELETE /api/messages/{id}", msgHandler.HandleDelete)

// Apply middleware chain: API Key → Rate Limiter → Router
// Apply middleware chain: CORS → API Key → Rate Limiter → Router
//
// CORS is outermost on purpose. A browser preflight is an OPTIONS request
// with no custom headers, so it carries no X-API-Key; if APIKeyAuth ran
// first every preflight would 401 and the real request would never be
// sent. CORS answers the preflight itself and lets everything else fall
// through to authentication as normal.
rateLimiter := middleware.NewRateLimiter(cfg.Security.RateLimit)
defer rateLimiter.Stop()

var h http.Handler = mux
h = rateLimiter.Middleware(h)
h = middleware.APIKeyAuth(cfg.Security.APIKey)(h)
h = middleware.CORS(cfg.Security.AllowedOrigins, cfg.Security.APIKey != "")(h)

if len(cfg.Security.AllowedOrigins) > 0 {
slog.Info("CORS enabled", "allowed_origins", cfg.Security.AllowedOrigins)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Create HTTP server
srv := &http.Server{
Expand Down
7 changes: 7 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,10 @@ messages:
security:
rate_limit: 30 # Requests per minute per IP
api_key: "" # Optional: if set, all requests must include X-API-Key header

# CORS allowlist for browsers calling the relay directly (no reverse proxy).
# Empty (the default) serves no CORS headers at all, unchanged from before.
# Use ["*"] to allow any origin. "*" cannot be mixed with specific origins.
# allowed_origins:
# - "https://app.example.com"
allowed_origins: []
68 changes: 68 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"log"
"os"
"strconv"
"strings"

"gopkg.in/yaml.v3"
)
Expand Down Expand Up @@ -39,6 +40,9 @@ type MessageConfig struct {
type SecurityConfig struct {
RateLimit int `yaml:"rate_limit"`
APIKey string `yaml:"api_key"`
// AllowedOrigins is the CORS allowlist. Empty (the default) serves no
// CORS headers at all. "*" allows any origin.
AllowedOrigins []string `yaml:"allowed_origins"`
}

// Default returns a Config with sensible defaults.
Expand All @@ -60,6 +64,8 @@ func Default() *Config {
Security: SecurityConfig{
RateLimit: 30,
APIKey: "",
// No origins: CORS stays off unless explicitly configured.
AllowedOrigins: nil,
},
}
}
Expand Down Expand Up @@ -144,6 +150,24 @@ func applyEnvOverrides(cfg *Config) {
if v := os.Getenv("RELAY_SECURITY_API_KEY"); v != "" {
cfg.Security.APIKey = v
}

if v := os.Getenv("RELAY_SECURITY_ALLOWED_ORIGINS"); v != "" {
cfg.Security.AllowedOrigins = splitOrigins(v)
}
}

// splitOrigins parses a comma-separated origin list from an environment
// variable, trimming spaces and dropping empty entries so that values like
// "https://a.example, https://b.example," behave as expected.
func splitOrigins(v string) []string {
parts := strings.Split(v, ",")
origins := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
origins = append(origins, p)
}
}
return origins
}

// Validate checks the configuration values for validity.
Expand All @@ -160,6 +184,50 @@ func (c *Config) Validate() error {
if c.Security.RateLimit < 0 {
return fmt.Errorf("invalid rate_limit: %d", c.Security.RateLimit)
}
if err := validateAllowedOrigins(c.Security.AllowedOrigins); err != nil {
return err
}
return nil
}

// validateAllowedOrigins rejects configurations that would not do what the
// operator expects: a wildcard mixed with specific origins (the wildcard
// silently wins, making the list misleading), and entries that are not
// scheme-qualified origins, which can never match a browser Origin header.
func validateAllowedOrigins(origins []string) error {
hasWildcard, hasSpecific := false, false

for _, o := range origins {
o = strings.TrimSpace(o)
if o == "" {
continue
}
if o == "*" {
hasWildcard = true
continue
}
hasSpecific = true

scheme, rest, found := strings.Cut(o, "://")
if !found || scheme == "" {
return fmt.Errorf(
"invalid allowed_origins entry %q: must be a full origin such as https://app.example.com", o)
}
// A single trailing slash is tolerated; anything more is a path.
host := strings.TrimSuffix(rest, "/")
if host == "" {
return fmt.Errorf(
"invalid allowed_origins entry %q: missing host", o)
}
if strings.Contains(host, "/") {
return fmt.Errorf(
"invalid allowed_origins entry %q: an origin has no path component", o)
}
}

if hasWildcard && hasSpecific {
return fmt.Errorf(`invalid allowed_origins: "*" cannot be combined with specific origins`)
}
return nil
}

Expand Down
Loading
Loading