Files
hzhang b02b1706b6 fix(oidc): clamp post_login_redirect to CORS allow-list (open-redirect)
Closes the open-redirect risk surfaced by the v0.3.x security audit:
the OIDC callback handler was using oidc_config.post_login_redirect
verbatim as the redirect base on both error and success paths. An
attacker with admin-key compromise (or a misconfigured operator) could
set that field to an external domain and turn /api/auth/oidc/callback
into a phishing redirector ('dialectic login failed → re-enter
password at evil.com/login').

Fix:
  - New AuthHandler.safeRedirectBase(raw) validator:
      * empty → '/'
      * relative path starting with '/' (but not '//') → keep as-is
      * absolute URL whose host is in the allow-list → keep as-is
      * everything else → '/'
  - allow-list sourced from cfg.CORSAllowOrigins (the same set we
    already trust for browser CORS), threaded through NewAuthHandler.
  - Applied on BOTH the error branch and success branch of Callback.

Also: drop redundant newline on cmd/dialectic-cli usage Fprintln so
go test ./... passes.

No behavior change for happy path: prod's PostLoginRedirect is
'https://dialectic.hangman-lab.top/oidc/callback', which matches the
CORS allow-list ('https://dialectic.hangman-lab.top'), so the
validator returns it unmodified.
2026-05-24 10:03:53 +01:00

146 lines
4.2 KiB
Go

// dialectic-cli — operator/admin commands for the running Dialectic
// backend. Reads the same DB the backend reads + writes; takes no
// HTTP path so it can run inside the same container as the backend
// (docker exec dialectic-backend dialectic-cli ...) or against the
// same DB from anywhere with credentials.
//
// Subcommands today:
//
// dialectic-cli config oidc [--issuer URL] [--client-id ID]
// [--client-secret S] [--callback-url URL]
// [--post-login-redirect URL]
// [--scopes "openid email profile"]
// [--enabled true|false]
// [--show-secret]
//
// Pattern mirrors Fabric.Backend.Center's `node dist/cli.js config oidc`.
// Only the flags you pass mutate; others stay unchanged. Default print
// masks client_secret (use --show-secret to reveal — local audit only,
// never paste output into chat).
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"time"
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/config"
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/db"
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/oidc"
)
func main() {
if len(os.Args) < 3 {
usage()
}
subject := os.Args[1]
action := os.Args[2]
args := os.Args[3:]
if subject != "config" || action != "oidc" {
usage()
}
patch := oidc.ConfigPatch{}
showSecret := false
for i := 0; i < len(args); i++ {
switch args[i] {
case "--issuer":
patch.Issuer = strArg(args, &i)
case "--client-id":
patch.ClientID = strArg(args, &i)
case "--client-secret":
patch.ClientSecret = strArg(args, &i)
case "--callback-url":
patch.RedirectURI = strArg(args, &i)
case "--post-login-redirect":
patch.PostLoginRedirect = strArg(args, &i)
case "--scopes":
patch.Scopes = strArg(args, &i)
case "--enabled":
v := strArg(args, &i)
b := v != nil && *v == "true"
patch.Enabled = &b
case "--show-secret":
showSecret = true
default:
fmt.Fprintf(os.Stderr, "unknown flag: %s\n", args[i])
usage()
}
}
cfg, err := config.LoadFromEnv()
check("config", err)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
conn, err := db.Open(ctx, cfg.DSN())
check("db", err)
defer conn.Close()
// Don't auto-run migrations from the CLI — the backend container's
// startup is the canonical migration driver. The CLI assumes the
// table is already in place.
signingKey := []byte(cfg.SessionSigningKey)
if len(signingKey) == 0 {
signingKey = []byte("cli-only-irrelevant")
}
svc := oidc.NewService(conn, signingKey, 24*time.Hour)
updated, err := svc.SetConfig(ctx, patch)
check("set config", err)
out := map[string]any{
"issuer": updated.Issuer,
"client_id": updated.ClientID,
"redirect_uri": updated.RedirectURI,
"post_login_redirect": updated.PostLoginRedirect,
"scopes": updated.Scopes,
"enabled": updated.Enabled,
}
if showSecret {
out["client_secret"] = updated.ClientSecret
} else if updated.ClientSecret != "" {
out["client_secret"] = "***set***"
} else {
out["client_secret"] = ""
}
b, _ := json.MarshalIndent(map[string]any{"ok": true, "config": out}, "", " ")
fmt.Println(string(b))
}
func strArg(args []string, i *int) *string {
if *i+1 >= len(args) {
fmt.Fprintf(os.Stderr, "%s requires a value\n", args[*i])
os.Exit(1)
}
*i++
v := args[*i]
return &v
}
func check(label string, err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", label, err)
os.Exit(1)
}
}
func usage() {
fmt.Fprint(os.Stderr, `Usage:
dialectic-cli config oidc [--issuer <url>] [--client-id <id>]
[--client-secret <s>] [--callback-url <url>]
[--post-login-redirect <url>]
[--scopes "openid email profile"]
[--enabled true|false] [--show-secret]
Sets only the flags you pass; leaves the rest unchanged. Prints the
post-update config with client_secret masked unless --show-secret.
Reads the same DB env as the backend (DB_HOST/PORT/USER/PASSWORD/NAME).
Run inside the backend container via:
docker exec dialectic-backend dialectic-cli config oidc ...
`)
os.Exit(1)
}