mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 16:02:48 +00:00
368 lines
12 KiB
Go
368 lines
12 KiB
Go
// Package formserver is the public face of hosted forms: a standalone HTTP
|
|
// service (cmd/forms) that serves the TanStack form app (forms/ at the repo
|
|
// root), its per-form page shells, the embed loader, and a same-origin JSON
|
|
// API that forwards to the backend over the internal API. It exists so
|
|
// nothing customer-facing answers on the API origin and form traffic scales
|
|
// on its own box. Like the workers and the tracking service, it holds no
|
|
// database: the backend owns validation, contacts and counters; this process
|
|
// owns the shell (with its per-form CSP), caching and the per-visitor abuse
|
|
// checks only it can see.
|
|
package formserver
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/warmbly/warmbly/internal/formwire"
|
|
)
|
|
|
|
// formMaxSubmitBytes caps a public submit body; the largest legitimate form
|
|
// is dozens of short answers.
|
|
const formMaxSubmitBytes = 64 << 10
|
|
|
|
const (
|
|
submitWindow = 10 * time.Minute
|
|
submitDefaultLimit = 30
|
|
viewDedupeTTL = 30 * time.Minute
|
|
maxSourceURLLen = 2048
|
|
// eventWindowLimit bounds funnel events per source IP per submitWindow; a
|
|
// long multi-page session emits a couple dozen.
|
|
eventWindowLimit = 120
|
|
// formMaxEventBytes caps an event body; it is a handful of short fields.
|
|
formMaxEventBytes = 8 << 10
|
|
)
|
|
|
|
type Config struct {
|
|
// BackendURL and InternalToken reach the backend's /api/v1/internal
|
|
// endpoints, the service's only dependency.
|
|
BackendURL string
|
|
InternalToken string
|
|
// StaticDir is the built forms app (forms/dist): index.html is the page
|
|
// shell, assets/ the hashed bundles.
|
|
StaticDir string
|
|
// SubmitLimit is submissions per source IP per 10 minutes; 0 means the
|
|
// default of 30.
|
|
SubmitLimit int
|
|
}
|
|
|
|
type Server struct {
|
|
client *backendClient
|
|
views *ttlSet
|
|
limits *ipLimiter
|
|
events *ipLimiter
|
|
renderKey []byte
|
|
shell []byte
|
|
assetsDir string
|
|
}
|
|
|
|
func New(cfg Config) (*Server, error) {
|
|
if cfg.BackendURL == "" {
|
|
return nil, errors.New("formserver: BackendURL is required")
|
|
}
|
|
if cfg.InternalToken == "" {
|
|
return nil, errors.New("formserver: InternalToken is required")
|
|
}
|
|
if cfg.StaticDir == "" {
|
|
return nil, errors.New("formserver: StaticDir is required")
|
|
}
|
|
shell, err := os.ReadFile(filepath.Join(cfg.StaticDir, "index.html"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("formserver: cannot read %s/index.html (run `pnpm build` in forms/): %w", cfg.StaticDir, err)
|
|
}
|
|
limit := cfg.SubmitLimit
|
|
if limit <= 0 {
|
|
limit = submitDefaultLimit
|
|
}
|
|
return &Server{
|
|
client: newBackendClient(strings.TrimRight(cfg.BackendURL, "/"), cfg.InternalToken),
|
|
views: newTTLSet(viewDedupeTTL),
|
|
limits: newIPLimiter(limit, submitWindow),
|
|
events: newIPLimiter(eventWindowLimit, submitWindow),
|
|
renderKey: renderKey(cfg.InternalToken),
|
|
shell: shell,
|
|
assetsDir: filepath.Join(cfg.StaticDir, "assets"),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) Router(trustedProxies []string) (*gin.Engine, error) {
|
|
r := gin.Default()
|
|
// Same posture as the backend: trust no proxy unless the operator names
|
|
// it, so a forged X-Forwarded-For cannot dodge the submit limiter.
|
|
if len(trustedProxies) > 0 {
|
|
if err := r.SetTrustedProxies(trustedProxies); err != nil {
|
|
return nil, err
|
|
}
|
|
} else if err := r.SetTrustedProxies(nil); err != nil {
|
|
return nil, err
|
|
}
|
|
r.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })
|
|
r.GET("/forms.js", s.ServeFormsEmbedJS)
|
|
r.GET("/f/:publicID", s.ServeFormShell)
|
|
|
|
// Hashed filenames, so the bundles are immutable by construction.
|
|
assets := r.Group("/assets", func(c *gin.Context) {
|
|
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
|
})
|
|
assets.Static("/", s.assetsDir)
|
|
|
|
// Every JSON call needs the render token minted with the page shell, so
|
|
// scripted clients that never load the page get nothing.
|
|
api := r.Group("/api", s.requireRenderToken)
|
|
api.GET("/forms/:publicID", s.GetPublicForm)
|
|
api.POST("/forms/:publicID/submit", s.SubmitForm)
|
|
api.POST("/forms/:publicID/events", s.RecordFormEvent)
|
|
return r, nil
|
|
}
|
|
|
|
// requireRenderToken rejects API calls whose X-Warmbly-Render token is
|
|
// missing, forged, expired or minted for another form. The app treats the
|
|
// stale_page error as "refresh this tab".
|
|
func (s *Server) requireRenderToken(c *gin.Context) {
|
|
token := c.GetHeader("X-Warmbly-Render")
|
|
if token == "" || !verifyRenderToken(s.renderKey, c.Param("publicID"), token, time.Now()) {
|
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "stale_page"})
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|
|
|
|
// fetchForm resolves a form or reports how the lookup ended.
|
|
func (s *Server) fetchForm(c *gin.Context) (*formwire.PublicForm, error) {
|
|
publicID := c.Param("publicID")
|
|
if publicID == "" || len(publicID) > 64 {
|
|
return nil, errFormNotFound
|
|
}
|
|
return s.client.Form(c.Request.Context(), publicID)
|
|
}
|
|
|
|
// wfTokenPlaceholder is the meta tag forms/index.html ships; the shell serve
|
|
// stamps the real render token into it.
|
|
const wfTokenPlaceholder = `<meta name="wf-token" content="" />`
|
|
|
|
// ServeFormShell serves the app shell for a published form. Public and
|
|
// unauthenticated: the unguessable public id is the capability. The form is
|
|
// resolved before serving so unknown ids 404 like any dead link, the
|
|
// per-form embed CSP rides on the document the browser frames, and the shell
|
|
// carries the render token the JSON API requires.
|
|
func (s *Server) ServeFormShell(c *gin.Context) {
|
|
f, err := s.fetchForm(c)
|
|
if errors.Is(err, errFormNotFound) {
|
|
formNotFoundPage(c)
|
|
return
|
|
}
|
|
if err != nil {
|
|
formUnavailablePage(c)
|
|
return
|
|
}
|
|
setFormFrameHeaders(c, f.AllowedDomains)
|
|
c.Header("Cache-Control", "no-store")
|
|
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
|
|
token := mintRenderToken(s.renderKey, f.PublicID, time.Now())
|
|
stamped := []byte(`<meta name="wf-token" content="` + token + `" />`)
|
|
shell := bytes.Replace(s.shell, []byte(wfTokenPlaceholder), stamped, 1)
|
|
if bytes.Equal(shell, s.shell) {
|
|
// Older build without the placeholder: inject before </head>.
|
|
shell = bytes.Replace(s.shell, []byte("</head>"), append(stamped, []byte("</head>")...), 1)
|
|
}
|
|
c.Data(http.StatusOK, "text/html; charset=utf-8", shell)
|
|
}
|
|
|
|
// isPrefetch reports whether the request is a prefetch or link preview, so
|
|
// they never count toward funnel stats.
|
|
func isPrefetch(c *gin.Context) bool {
|
|
for _, header := range []string{"Sec-Purpose", "Purpose", "X-Purpose", "X-Moz"} {
|
|
if v := c.GetHeader(header); v != "" && strings.Contains(strings.ToLower(v), "prefetch") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// GetPublicForm hands the app the form definition: what the visitor may see
|
|
// and nothing more (the embed allowlist stays server-side with the CSP). A
|
|
// ?t= ticket bypasses the cache so prefill never leaks across visitors.
|
|
func (s *Server) GetPublicForm(c *gin.Context) {
|
|
var f *formwire.PublicForm
|
|
var err error
|
|
if token := c.Query("t"); token != "" && len(token) <= 64 {
|
|
f, err = s.client.FormWithToken(c.Request.Context(), c.Param("publicID"), token)
|
|
} else {
|
|
f, err = s.fetchForm(c)
|
|
}
|
|
if errors.Is(err, errFormNotFound) {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "not_found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "unavailable"})
|
|
return
|
|
}
|
|
c.Header("Cache-Control", "no-store")
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"public_id": f.PublicID,
|
|
"name": f.Name,
|
|
"fields": f.Fields,
|
|
"design": f.Design,
|
|
"logo_url": f.LogoURL,
|
|
"cover_url": f.CoverURL,
|
|
"background_url": f.BackgroundURL,
|
|
"captcha_site_key": f.CaptchaSiteKey,
|
|
"prefill": f.Prefill,
|
|
"link_token": f.LinkToken,
|
|
})
|
|
}
|
|
|
|
// eventBody is what the app beacons; keep in sync with forms/src/events.ts.
|
|
type eventBody struct {
|
|
Type string `json:"type"`
|
|
PageIndex int `json:"page_index"`
|
|
PagesTotal int `json:"pages_total"`
|
|
VisitorKey string `json:"visitor_key"`
|
|
SourceURL string `json:"source_url"`
|
|
LinkToken string `json:"link_token"`
|
|
}
|
|
|
|
// RecordFormEvent accepts a funnel beacon, applies the checks only this
|
|
// process can make (prefetch filter, per-IP budget, view dedupe) and
|
|
// forwards it for enrichment. Always 204: a beacon never surfaces errors.
|
|
func (s *Server) RecordFormEvent(c *gin.Context) {
|
|
if isPrefetch(c) {
|
|
c.Status(http.StatusNoContent)
|
|
return
|
|
}
|
|
ip := c.ClientIP()
|
|
if ip != "" && !s.events.Allow(ip) {
|
|
c.Status(http.StatusNoContent)
|
|
return
|
|
}
|
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, formMaxEventBytes)
|
|
var body eventBody
|
|
if err := json.NewDecoder(c.Request.Body).Decode(&body); err != nil {
|
|
c.Status(http.StatusNoContent)
|
|
return
|
|
}
|
|
publicID := c.Param("publicID")
|
|
if body.Type == "view" && !s.views.Add(publicID+":"+truncateStr(body.VisitorKey, 64)) {
|
|
c.Status(http.StatusNoContent)
|
|
return
|
|
}
|
|
sourceURL := body.SourceURL
|
|
if sourceURL == "" {
|
|
sourceURL = c.GetHeader("Referer")
|
|
}
|
|
if len(sourceURL) > maxSourceURLLen {
|
|
sourceURL = sourceURL[:maxSourceURLLen]
|
|
}
|
|
go s.client.RecordEvent(publicID, &formwire.EventRequest{
|
|
Type: body.Type,
|
|
PageIndex: body.PageIndex,
|
|
PagesTotal: body.PagesTotal,
|
|
VisitorKey: truncateStr(body.VisitorKey, 64),
|
|
SourceURL: sourceURL,
|
|
LinkToken: truncateStr(body.LinkToken, 64),
|
|
RemoteIP: ip,
|
|
UserAgent: truncateStr(c.GetHeader("User-Agent"), 512),
|
|
})
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
func truncateStr(s string, n int) string {
|
|
if len(s) > n {
|
|
return s[:n]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// submitBody is what the app posts; keep in sync with forms/src/api.ts.
|
|
type submitBody struct {
|
|
Answers map[string][]string `json:"answers"`
|
|
// Website is the honeypot value; a human never fills it.
|
|
Website string `json:"website"`
|
|
WT int64 `json:"_wt"`
|
|
CaptchaToken string `json:"captcha_token"`
|
|
SourceURL string `json:"source_url"`
|
|
LinkToken string `json:"link_token"`
|
|
VisitorKey string `json:"visitor_key"`
|
|
}
|
|
|
|
// SubmitForm accepts a public submission, runs the checks only this process
|
|
// can make (per-IP budget, body cap) and forwards the rest to the backend.
|
|
// Same-origin with the app, so there is no CORS surface.
|
|
func (s *Server) SubmitForm(c *gin.Context) {
|
|
fail := func(status int, msg string) {
|
|
c.JSON(status, gin.H{"error": "form_submit_failed", "message": msg})
|
|
}
|
|
|
|
if ip := c.ClientIP(); ip != "" && !s.limits.Allow(ip) {
|
|
c.Header("Retry-After", fmt.Sprintf("%d", int(submitWindow.Seconds())))
|
|
c.JSON(http.StatusTooManyRequests, gin.H{
|
|
"error": "rate_limit_exceeded",
|
|
"message": "Too many submissions from this address. Try again later.",
|
|
"code": "rate_limit_exceeded",
|
|
})
|
|
return
|
|
}
|
|
|
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, formMaxSubmitBytes)
|
|
var body submitBody
|
|
if err := json.NewDecoder(c.Request.Body).Decode(&body); err != nil {
|
|
var maxErr *http.MaxBytesError
|
|
if errors.As(err, &maxErr) {
|
|
fail(http.StatusRequestEntityTooLarge, "The submission is too large.")
|
|
return
|
|
}
|
|
fail(http.StatusBadRequest, "The submission could not be read.")
|
|
return
|
|
}
|
|
|
|
sourceURL := body.SourceURL
|
|
if sourceURL == "" {
|
|
sourceURL = c.GetHeader("Referer")
|
|
}
|
|
if len(sourceURL) > maxSourceURLLen {
|
|
sourceURL = sourceURL[:maxSourceURLLen]
|
|
}
|
|
|
|
req := &formwire.SubmitRequest{
|
|
Answers: body.Answers,
|
|
RemoteIP: c.ClientIP(),
|
|
SourceURL: sourceURL,
|
|
CaptchaToken: body.CaptchaToken,
|
|
HoneypotFilled: strings.TrimSpace(body.Website) != "",
|
|
RenderedAt: body.WT,
|
|
LinkToken: truncateStr(body.LinkToken, 64),
|
|
VisitorKey: truncateStr(body.VisitorKey, 64),
|
|
}
|
|
|
|
out, err := s.client.Submit(c.Request.Context(), c.Param("publicID"), req)
|
|
if err != nil {
|
|
fail(http.StatusBadGateway, "Something went wrong. Please try again.")
|
|
return
|
|
}
|
|
switch {
|
|
case out.NotFound:
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "not_found"})
|
|
return
|
|
case out.Rejected != "":
|
|
fail(http.StatusBadRequest, out.Rejected)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, out.Result)
|
|
}
|
|
|
|
// ServeFormsEmbedJS serves the embed loader. Cached for an hour, mirroring
|
|
// the tracking service's tracking.js.
|
|
func (s *Server) ServeFormsEmbedJS(c *gin.Context) {
|
|
c.Header("Cache-Control", "public, max-age=3600")
|
|
c.Data(http.StatusOK, "application/javascript; charset=utf-8", formsEmbedJS)
|
|
}
|