This commit is contained in:
+21
-21
@@ -15,14 +15,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
maxBodyBytes = 32 << 10 // 32 KiB is far more than this form needs
|
||||
maxBodyBytes = 32 << 10
|
||||
perIPLimit = 5
|
||||
perIPWindow = 10 * time.Minute
|
||||
)
|
||||
|
||||
// Server backs the marketing site's two forms: contact, which is emailed and
|
||||
// never stored, and signup, which provisions an organisation and its owner
|
||||
// after the address has been verified.
|
||||
|
||||
|
||||
|
||||
type Server struct {
|
||||
mail mail.Config
|
||||
limiter *limiter
|
||||
@@ -49,7 +49,7 @@ func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/contact", s.handleContact)
|
||||
mux.HandleFunc("POST /api/signup", s.handleSignup)
|
||||
// Opened from an email client, so it is a GET that renders a page.
|
||||
|
||||
mux.HandleFunc("GET /api/verify", s.handleVerify)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
@@ -57,7 +57,7 @@ func (s *Server) Routes() http.Handler {
|
||||
return s.withCORS(mux)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- middleware
|
||||
|
||||
|
||||
func parseOrigins(raw string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
@@ -69,10 +69,10 @@ func parseOrigins(raw string) map[string]bool {
|
||||
return out
|
||||
}
|
||||
|
||||
// withCORS reflects only origins named in SITE_ORIGIN. It never answers with a
|
||||
// wildcard: this endpoint sends mail, and an unset SITE_ORIGIN should fail
|
||||
// closed for cross-origin callers rather than open to every site on the
|
||||
// internet.
|
||||
|
||||
|
||||
|
||||
|
||||
func (s *Server) withCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
@@ -91,9 +91,9 @@ func (s *Server) withCORS(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// clientIP prefers the left-most X-Forwarded-For entry, but only when the
|
||||
// service is explicitly told it sits behind a proxy. Trusting the header
|
||||
// unconditionally would let any caller spoof its way past the rate limiter.
|
||||
|
||||
|
||||
|
||||
func (s *Server) clientIP(r *http.Request) string {
|
||||
if s.trustProxy {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
@@ -110,7 +110,7 @@ func (s *Server) clientIP(r *http.Request) string {
|
||||
return host
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- handler
|
||||
|
||||
|
||||
type contactBody struct {
|
||||
Name string `json:"name"`
|
||||
@@ -118,7 +118,7 @@ type contactBody struct {
|
||||
Servers string `json:"servers"`
|
||||
Topic string `json:"topic"`
|
||||
Message string `json:"message"`
|
||||
Website string `json:"website"` // honeypot: real people leave this empty
|
||||
Website string `json:"website"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -137,8 +137,8 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// A filled honeypot is a bot. Answer exactly as we would on success so it
|
||||
// learns nothing, and send nothing.
|
||||
|
||||
|
||||
if strings.TrimSpace(body.Website) != "" {
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "received"})
|
||||
return
|
||||
@@ -201,9 +201,9 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Nothing is stored, so the send has to succeed before we can tell someone
|
||||
// their message arrived. This is the one place where a mail failure is the
|
||||
// caller's problem.
|
||||
|
||||
|
||||
|
||||
if err := s.mail.Send(subject(addr, fields), plainBody(addr, fields), addr); err != nil {
|
||||
log.Printf("contact send: %v", err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||
@@ -233,7 +233,7 @@ func plainBody(addr string, fields map[string]string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// limiter is a fixed-window counter keyed by client IP. It exists to blunt
|
||||
// automated submission floods, not to be a precise quota: the window resets
|
||||
// wholesale, and state is per-process, so it is a speed bump rather than a
|
||||
// guarantee. The per-email check in the handler backs it up.
|
||||
|
||||
|
||||
|
||||
|
||||
type limiter struct {
|
||||
mu sync.Mutex
|
||||
hits map[string]*window
|
||||
@@ -51,8 +51,8 @@ func (l *limiter) allow(key string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// gc drops expired windows so a long-running process does not accumulate an
|
||||
// entry for every IP that ever hit it. Caller must hold the lock.
|
||||
|
||||
|
||||
func (l *limiter) gc(now time.Time) {
|
||||
if now.Sub(l.lastGC) < l.window {
|
||||
return
|
||||
|
||||
@@ -25,20 +25,20 @@ type signupBody struct {
|
||||
OrgName string `json:"org_name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Website string `json:"website"` // honeypot
|
||||
Website string `json:"website"`
|
||||
}
|
||||
|
||||
// handleSignup records an unverified signup and emails the confirmation link.
|
||||
// Nothing is created in orgs or users until that link is opened, so an address
|
||||
// nobody controls can never occupy an email or hold an organisation slug.
|
||||
|
||||
|
||||
|
||||
func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
var body signupBody
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
// A filled honeypot is a bot. Answer as we would on success so it learns
|
||||
// nothing, and record nothing.
|
||||
|
||||
|
||||
if strings.TrimSpace(body.Website) != "" {
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "check_email"})
|
||||
return
|
||||
@@ -114,9 +114,9 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
link := s.verifyURL(token)
|
||||
if err := s.mail.SendVerification(addr, orgName, link, store.PendingTTL); err != nil {
|
||||
// The pending record is useless without its email, and the address is
|
||||
// not registered, so the caller must be told rather than left waiting
|
||||
// for a message that will never arrive.
|
||||
|
||||
|
||||
|
||||
log.Printf("signup: send verification to %s: %v", addr, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||
"error": "We could not send the confirmation email. Check the address, or email support@hostxtra.co.uk.",
|
||||
@@ -132,9 +132,9 @@ func (s *Server) verifyURL(token string) string {
|
||||
return fmt.Sprintf("%s/api/verify?token=%s", base, url.QueryEscape(token))
|
||||
}
|
||||
|
||||
// handleVerify consumes the token and provisions the organisation. It is opened
|
||||
// from an email client, so it answers with a page rather than JSON, and
|
||||
// redirects to the app's sign-in page on success when one is configured.
|
||||
|
||||
|
||||
|
||||
func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
@@ -178,9 +178,9 @@ func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Sprintf("%s is set up and you are its owner. You can sign in now.", org.Name))
|
||||
}
|
||||
|
||||
// verifyPage renders a minimal self-contained page. Everything interpolated is
|
||||
// escaped: the only dynamic value is an organisation name the visitor supplied
|
||||
// themselves, but it still reaches a browser as HTML.
|
||||
|
||||
|
||||
|
||||
func (s *Server) verifyPage(w http.ResponseWriter, status int, heading, detail string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Deliberately loose: the only thing worth rejecting here is something that
|
||||
// cannot be an address at all. Anything stricter starts refusing valid mail.
|
||||
|
||||
|
||||
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s.]+\.[^@\s]+$`)
|
||||
|
||||
const (
|
||||
@@ -23,9 +23,9 @@ type fieldError struct {
|
||||
|
||||
func (e fieldError) Error() string { return e.Field + ": " + e.Message }
|
||||
|
||||
// text trims, rejects empties when required, and caps length. The cap is on
|
||||
// runes rather than bytes so a multi-byte message is not silently truncated
|
||||
// mid-character.
|
||||
|
||||
|
||||
|
||||
func text(name, value string, required bool, max int) (string, *fieldError) {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
@@ -55,9 +55,9 @@ func email(name, value string) (string, *fieldError) {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// oneOf constrains a value to a known set. Submitted values for dropdowns are
|
||||
// as attacker-controlled as any other field, so they are checked rather than
|
||||
// trusted and stored.
|
||||
|
||||
|
||||
|
||||
func oneOf(name, value string, allowed []string) (string, *fieldError) {
|
||||
v := strings.TrimSpace(value)
|
||||
for _, a := range allowed {
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
|
||||
const timeout = 15 * time.Second
|
||||
|
||||
// Config is read once at boot. When Host is empty the service still accepts and
|
||||
// stores submissions; it just does not email them.
|
||||
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port string
|
||||
@@ -41,19 +41,19 @@ func (c Config) Enabled() bool {
|
||||
return c.Host != "" && c.From != "" && c.To != ""
|
||||
}
|
||||
|
||||
// Port 465 uses implicit TLS; any other port starts plain and upgrades with
|
||||
// STARTTLS when the server advertises it. Dial and connection deadlines keep an
|
||||
// unreachable host from hanging the caller until the OS TCP timeout.
|
||||
// Send delivers a plain-text message. replyTo, when set, becomes the Reply-To
|
||||
// header so hitting reply in a mail client answers the person who filled in the
|
||||
// form rather than the service's own sending address. The envelope sender stays
|
||||
// as From, so a submitted address can never affect SPF or DMARC alignment.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
func (c Config) Send(subject, body, replyTo string) error {
|
||||
return c.sendTo(c.To, subject, body, replyTo)
|
||||
}
|
||||
|
||||
// sendTo delivers to an explicit recipient. Contact enquiries go to the support
|
||||
// inbox (c.To); verification links go to the person signing up.
|
||||
|
||||
|
||||
func (c Config) sendTo(to, subject, body, replyTo string) error {
|
||||
if !c.Enabled() {
|
||||
return fmt.Errorf("smtp: not configured")
|
||||
@@ -127,9 +127,9 @@ func recipients(to string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// message builds the MIME body. The subject is encoded rather than interpolated
|
||||
// raw, and headers are stripped of CR/LF so submitted content cannot inject
|
||||
// extra headers.
|
||||
|
||||
|
||||
|
||||
func message(from, to, subject, body, replyTo string) []byte {
|
||||
var b strings.Builder
|
||||
b.WriteString("From: " + sanitizeHeader(from) + "\r\n")
|
||||
@@ -137,10 +137,10 @@ func message(from, to, subject, body, replyTo string) []byte {
|
||||
if replyTo != "" {
|
||||
b.WriteString("Reply-To: " + sanitizeHeader(replyTo) + "\r\n")
|
||||
}
|
||||
// Date and Message-ID are RFC 5322 essentials. Without them many servers
|
||||
// accept the message at SMTP time and then silently junk or drop it, and
|
||||
// SpamAssassin scores MISSING_DATE and MISSING_MID heavily — the message
|
||||
// "sends" but never lands in the inbox.
|
||||
|
||||
|
||||
|
||||
|
||||
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
|
||||
b.WriteString("Message-ID: " + messageID(from) + "\r\n")
|
||||
b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", sanitizeHeader(subject)) + "\r\n")
|
||||
@@ -151,9 +151,9 @@ func message(from, to, subject, body, replyTo string) []byte {
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// messageID builds a unique <id@domain>, taking the domain from the From
|
||||
// address so the identifier matches the sending domain. Falls back to the host
|
||||
// name when From has no domain part.
|
||||
|
||||
|
||||
|
||||
func messageID(from string) string {
|
||||
domain := "vantage.local"
|
||||
if at := strings.LastIndex(from, "@"); at >= 0 && at < len(from)-1 {
|
||||
@@ -170,9 +170,9 @@ func sanitizeHeader(v string) string {
|
||||
return strings.NewReplacer("\r", " ", "\n", " ").Replace(v)
|
||||
}
|
||||
|
||||
// SendVerification emails the one-time link that completes a signup. The
|
||||
// address is the person signing up, not the support inbox, so To is overridden
|
||||
// for this one message.
|
||||
|
||||
|
||||
|
||||
func (c Config) SendVerification(to, orgName, link string, ttl time.Duration) error {
|
||||
body := fmt.Sprintf(`Confirm your email to finish creating %s on Vantage.
|
||||
|
||||
|
||||
@@ -35,13 +35,13 @@ type User struct {
|
||||
LastLogin *time.Time `bson:"last_login,omitempty"`
|
||||
}
|
||||
|
||||
// PendingSignup is sitesvc's own record, in its own collection. It holds a
|
||||
// signup between the form being submitted and the email link being clicked.
|
||||
//
|
||||
// Nothing is written to orgs or users until verification succeeds, so an
|
||||
// unverified address can never occupy an email, hold a slug, or sign in. The
|
||||
// password is bcrypt-hashed here exactly as it would be in users, so the
|
||||
// plaintext never rests anywhere.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
type PendingSignup struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty"`
|
||||
PendingID string `bson:"pending_id"`
|
||||
|
||||
@@ -23,13 +23,13 @@ does not agree with.
|
||||
const (
|
||||
MinSlugLength = 3
|
||||
MaxSlugLength = 40
|
||||
BcryptCost = 12 // matches services.CreateUser
|
||||
BcryptCost = 12
|
||||
)
|
||||
|
||||
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// ReservedSlugs are names that would collide with a route or a host label.
|
||||
// Mirrored from services.reservedSlugs.
|
||||
|
||||
|
||||
var ReservedSlugs = map[string]bool{
|
||||
"www": true, "api": true, "app": true, "admin": true, "auth": true,
|
||||
"install": true, "static": true, "_next": true, "default": true,
|
||||
@@ -41,8 +41,8 @@ func Slugify(name string) string {
|
||||
return strings.Trim(s, "-")
|
||||
}
|
||||
|
||||
// BaseSlug derives and validates the slug for an organisation name, returning
|
||||
// the same errors the control plane's CreateOrg would.
|
||||
|
||||
|
||||
func BaseSlug(name string) (string, error) {
|
||||
base := Slugify(name)
|
||||
if len(base) < MinSlugLength {
|
||||
@@ -57,7 +57,7 @@ func BaseSlug(name string) (string, error) {
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// NextSlug is the collision suffix scheme: base, base-2, base-3, ...
|
||||
|
||||
func NextSlug(base string, attempt int) string {
|
||||
if attempt < 2 {
|
||||
return base
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// PendingTTL is how long a verification link stays valid.
|
||||
|
||||
const PendingTTL = 24 * time.Hour
|
||||
|
||||
var (
|
||||
@@ -32,15 +32,15 @@ var (
|
||||
|
||||
var database *mongo.Database
|
||||
|
||||
// Connect dials MongoDB and selects the database named in the connection
|
||||
// string, e.g. mongodb://host:27017/vantage. The name is parsed with the
|
||||
// driver's own connection-string parser rather than by hand, so seed lists,
|
||||
// mongodb+srv, percent-escaping and auth options all behave as the driver
|
||||
// expects.
|
||||
//
|
||||
// A URI with no database is a configuration error worth failing on: defaulting
|
||||
// would silently provision tenants into the wrong database, where the control
|
||||
// plane would never see them.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
func Connect(uri string) error {
|
||||
cs, err := connstring.ParseAndValidate(uri)
|
||||
if err != nil {
|
||||
@@ -64,7 +64,7 @@ func Connect(uri string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DatabaseName reports the database in use, for startup logging.
|
||||
|
||||
func DatabaseName() string {
|
||||
if database == nil {
|
||||
return ""
|
||||
@@ -98,8 +98,8 @@ func EnsureIndexes() error {
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
{Keys: bson.D{{Key: "email", Value: 1}}},
|
||||
// Mongo removes expired pending signups on its own, so an abandoned
|
||||
// signup does not keep a password hash around indefinitely.
|
||||
|
||||
|
||||
{
|
||||
Keys: bson.D{{Key: "expires_at", Value: 1}},
|
||||
Options: options.Index().SetExpireAfterSeconds(0),
|
||||
@@ -139,8 +139,8 @@ func CreatePending(ctx context.Context, orgName, email, password string) (string
|
||||
return "", err
|
||||
}
|
||||
|
||||
// A second attempt for the same address replaces the first, so the newest
|
||||
// email is the one that works and old links stop functioning.
|
||||
|
||||
|
||||
if _, err := col("site_pending_signups").DeleteMany(ctx, bson.M{"email": email}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -161,11 +161,11 @@ func CreatePending(ctx context.Context, orgName, email, password string) (string
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// Verify consumes a token and provisions the organisation and its owner.
|
||||
//
|
||||
// The pending record is deleted first and atomically, so a token can only ever
|
||||
// be spent once even if the link is clicked twice at the same moment: the
|
||||
// second delete matches nothing and stops here.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
func Verify(ctx context.Context, rawToken string) (*models.Org, error) {
|
||||
var pending models.PendingSignup
|
||||
err := col("site_pending_signups").FindOneAndDelete(ctx, bson.M{
|
||||
@@ -194,10 +194,10 @@ func Verify(ctx context.Context, rawToken string) (*models.Org, error) {
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := col("users").InsertOne(ctx, user); err != nil {
|
||||
// An org with no owner is unreachable and holds a slug nobody can
|
||||
// reuse, so take it back out. Losing the pending record here is
|
||||
// acceptable: the address is already registered, which is what the
|
||||
// duplicate error means.
|
||||
|
||||
|
||||
|
||||
|
||||
if rbErr := rollbackOrg(ctx, org.OrgID); rbErr != nil {
|
||||
log.Printf("verify: failed to roll back org %s: %v", org.OrgID, rbErr)
|
||||
}
|
||||
@@ -210,8 +210,8 @@ func Verify(ctx context.Context, rawToken string) (*models.Org, error) {
|
||||
return org, nil
|
||||
}
|
||||
|
||||
// createOrg mirrors services.CreateOrg: derive the slug, resolve collisions by
|
||||
// suffixing, and let the unique index settle any race.
|
||||
|
||||
|
||||
func createOrg(ctx context.Context, name string) (*models.Org, error) {
|
||||
base, err := provision.BaseSlug(name)
|
||||
if err != nil {
|
||||
@@ -236,8 +236,8 @@ func createOrg(ctx context.Context, name string) (*models.Org, error) {
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := col("orgs").InsertOne(ctx, org); err != nil {
|
||||
// Another signup took this slug between the count and the insert.
|
||||
// Try the next suffix rather than failing the whole signup.
|
||||
|
||||
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
continue
|
||||
}
|
||||
@@ -248,8 +248,8 @@ func createOrg(ctx context.Context, name string) (*models.Org, error) {
|
||||
return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name)
|
||||
}
|
||||
|
||||
// rollbackOrg removes an org that never got an owner. It refuses to touch one
|
||||
// that has users, so a mistaken call can never delete a live tenant.
|
||||
|
||||
|
||||
func rollbackOrg(ctx context.Context, orgID string) error {
|
||||
n, err := col("users").CountDocuments(ctx, bson.M{"org_id": orgID})
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user