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.
146 lines
4.2 KiB
Go
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)
|
|
}
|