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
141 changes: 141 additions & 0 deletions backend/app/api/cli_reset_password.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package main

import (
"context"
"database/sql"
"errors"
"flag"
"fmt"
"os"
"strings"

"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
"github.com/pressly/goose/v3"
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
"github.com/sysadminsmedia/homebox/backend/internal/core/services/reporting/eventbus"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
"github.com/sysadminsmedia/homebox/backend/internal/data/migrations"
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
)

// runResetPasswordCLI handles `homebox reset-password --email=...`. It mints a
// one-time reset link and prints it to stdout. This is the escape hatch for
// installations without SMTP, or for debugging the password matching path
// itself — the cases where the email-based flow can't help.
//
// Returns true when it consumed the command (and the caller should exit), so
// `homebox` with no subcommand still falls through to the server.
func runResetPasswordCLI(args []string) (handled bool, exitCode int) {
if len(args) < 2 || args[1] != "reset-password" {
return false, 0
}

fs := flag.NewFlagSet("reset-password", flag.ContinueOnError)
email := fs.String("email", "", "Email address of the account to reset")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "Usage: homebox reset-password --email=<address>")
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "Generates a one-time password reset link for the given account and")
fmt.Fprintln(os.Stderr, "prints it to stdout. The link expires in one hour and can be used once.")
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "All HBOX_* environment variables (database, hostname) are honored.")
fs.PrintDefaults()
}
if err := fs.Parse(args[2:]); err != nil {
return true, 2
}
trimmedEmail := strings.TrimSpace(*email)
if trimmedEmail == "" {
fs.Usage()
return true, 2
}

cfg, err := config.New(build(), "Homebox inventory management system")
if err != nil {
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
return true, 1
}

link, err := generateResetLinkOffline(cfg, trimmedEmail)
if err != nil {
if ent.IsNotFound(err) {
fmt.Fprintf(os.Stderr, "no account found for %s\n", trimmedEmail)
return true, 1
}
fmt.Fprintf(os.Stderr, "failed to generate reset link: %v\n", err)
return true, 1
}

fmt.Println(link)
return true, 0
}

// generateResetLinkOffline opens the database, runs migrations if needed, and
// mints a token without starting the HTTP server. The returned link uses
// HBOX_OPTIONS_HOSTNAME if set; otherwise it's emitted as a path the operator
// can append to whatever URL their instance is reachable at.
func generateResetLinkOffline(cfg *config.Config, email string) (string, error) {
databaseURL, err := setupDatabaseURL(cfg)
if err != nil {
return "", fmt.Errorf("setup database url: %w", err)
}

driver := strings.ToLower(cfg.Database.Driver)
var driverName, dialectName string
switch driver {
case config.DriverPostgres:
driverName = "pgx"
dialectName = dialect.Postgres
case config.DriverSqlite3, "sqlite":
driverName = "sqlite3"
dialectName = dialect.SQLite
default:
return "", fmt.Errorf("unsupported driver: %s", driver)
}

db, err := sql.Open(driverName, databaseURL)
if err != nil {
return "", fmt.Errorf("open db: %w", err)
}
defer func() { _ = db.Close() }()

drv := entsql.OpenDB(dialectName, db)
c := ent.NewClient(ent.Driver(drv))
defer func() { _ = c.Close() }()

migrationsFs, err := migrations.Migrations(driver)
if err != nil {
return "", fmt.Errorf("load migrations: %w", err)
}
goose.SetBaseFS(migrationsFs)
if err := goose.SetDialect(driver); err != nil {
return "", fmt.Errorf("set dialect: %w", err)
}
if err := goose.Up(c.Sql(), driver); err != nil {
return "", fmt.Errorf("apply migrations: %w", err)
}

bus := eventbus.New()
repos := repo.New(c, bus, cfg.Storage, cfg.Database.PubSubConnString, cfg.Thumbnail)
svc := services.New(repos)

baseURL := strings.TrimSuffix(cfg.Options.Hostname, "/")
if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
// Hostname without a scheme: assume https. The CLI has no request to
// inspect for r.TLS, and https is the safer default to print.
baseURL = "https://" + baseURL
}

link, err := svc.User.GenerateResetLink(context.Background(), email, baseURL)
if err != nil {
if errors.Is(err, services.ErrorMailerNotConfigured) {
// Should never happen here; GenerateResetLink doesn't touch the
// mailer. Defensive in case future refactors reorder things.
return "", err
}
return "", err
}
return link, nil
}
86 changes: 83 additions & 3 deletions backend/app/api/handlers/v1/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,94 @@ func ensureScheme(hostname string, r *http.Request, trustProxy bool) string {
// getScheme determines the appropriate URL scheme based on request and proxy settings
func getScheme(r *http.Request, trustProxy bool) string {
if r.TLS != nil {
return "https"
return schemeHTTPS
}
if trustProxy && r.Header.Get("X-Forwarded-Proto") == "https" {
return "https"
if trustProxy {
// X-Forwarded-Proto may be a comma-separated list (one entry per hop);
// the leftmost entry is the original client-facing protocol — that's
// what we want for user-facing URL construction. A literal equality
// check fails on legitimate "https, http" multi-hop values.
proto := strings.ToLower(firstHeaderValue(r.Header.Get("X-Forwarded-Proto")))
if proto == schemeHTTPS {
return schemeHTTPS
}
}
return "http"
}

// firstHeaderValue returns the first comma-separated value from a header
// field, trimmed. RFC 7230 §3.2.2 allows multiple instances of a field to
// be combined with commas; net/http's Header.Get returns only the first
// occurrence but does NOT split combined values, so a header like
// `X-Forwarded-Host: a, b` would otherwise be embedded verbatim into URLs.
func firstHeaderValue(v string) string {
if i := strings.IndexByte(v, ','); i >= 0 {
v = v[:i]
}
return strings.TrimSpace(v)
}

// validProxyHost reports whether s is a syntactically valid host:port (or
// host) value, with no scheme, path, query, fragment, whitespace, or control
// characters. Callers building URLs from untrusted X-Forwarded-Host must run
// this check — without it, a misconfigured proxy that forwards client-set
// headers could let an attacker inject path/CRLF/full-URL payloads into a
// password-reset email link.
func validProxyHost(s string) bool {
if s == "" {
return false
}
// Reject control characters, whitespace, and structural URL characters
// outright. Hosts legitimately include letters, digits, dot, dash, colon
// (port separator), and brackets (IPv6).
for _, r := range s {
if r < 0x20 || r == 0x7f {
return false
}
}
if strings.ContainsAny(s, " \t/?#\\") {
return false
}
if strings.Contains(s, "://") {
return false
}
// Final structural check: url.Parse should round-trip the string as the
// authority component. If it doesn't, something funny is going on.
u, err := url.Parse("http://" + s)
if err != nil || u.Host != s || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
return false
}
return true
}

// SecureBaseURL returns a base URL safe to embed in security-sensitive emails
// (password reset, etc.). Unlike GetHBURL it deliberately omits the Referer
// fallback, since Referer is unauthenticated client input — an attacker who
// can reach /forgot-password could otherwise poison the link in the victim's
// reset email and phish the new password.
//
// X-Forwarded-Host is honored only when the operator has opted into
// TrustProxy. Even then, the value is parsed via firstHeaderValue (multi-hop
// safe) and validated via validProxyHost so a misconfigured proxy that
// forwards client-supplied headers can't inject schemes, paths, CRLF, or
// extra hosts into the link.
//
// Returns "" when no trusted source is available; callers must refuse the
// operation in that case.
func SecureBaseURL(r *http.Request, options *config.Options) string {
if options.Hostname != "" {
return ensureScheme(options.Hostname, r, options.TrustProxy)
}
if options.TrustProxy {
host := firstHeaderValue(r.Header.Get("X-Forwarded-Host"))
if !validProxyHost(host) {
return ""
}
return getScheme(r, options.TrustProxy) + "://" + host
}
return ""
}

// stripPathFromURL removes the path from a URL.
// ex. https://example.com/tools -> https://example.com
func stripPathFromURL(rawURL string) string {
Expand Down
Loading
Loading