Address code review findings across API, web and deployment
release gate / check (push) Failing after 6m43s

High severity:
- wire TRUSTED_PROXY through Compose/deploy/docs and warn at web startup
- bucket public rate limits on effective client IP, add per-bookmark budget,
  and forward the signed browser identity from the share page
- disable first-run bootstrap in server deployments and document seeding
- serialize last-admin demotion in one advisory-locked transaction

Medium/low:
- cap and batch share recipient inserts
- add a shared outbound budget for preview/CardDAV/IMAP/signal work
- pin CardDAV dials to 443 and bound IMAP logout
- fix rightward kanban column drags and normalize cards on column delete
- classify backend outages in auth guards, add request timeouts and an
  error boundary, remove the dead login route, surface throttling
- enforce pinned/unpinned page reorder grouping server-side
- avoid preview work for stale bookmark versions; drop duplicate dialog fetch
- sequence-guard collection/tag/calendar/quick-find loads
- validate property config and fix MULTI_SELECT/DATE filter inputs
- document index maintenance; apply same-origin check to sign-out
- trim signup names, unique shared dates and kanban keys (migration 010),
  reset reference cache on identity changes, hide OIDC issuer, return 413
  for oversized direct bodies, escape Markdown link destinations
- remove dead template files and duplicate test helper

Verified with go build/vet, full PostgreSQL race suite, db convergence,
tsc, both ESLint configs and a Next production build.
This commit is contained in:
Andrew S Erwin
2026-10-20 00:13:30 -05:00
parent 5eab58f99c
commit 4e421f6ea2
58 changed files with 1419 additions and 333 deletions
+9 -1
View File
@@ -318,6 +318,14 @@ func HandleBookmarksUpdate(w http.ResponseWriter, r *http.Request) {
respond(w, 0, nil, e)
return
}
// Review M-10: reject a stale base version BEFORE doing outbound preview
// work. The transaction below still re-checks under the user lock; this
// early check only avoids spending up to 8 seconds and the shared preview
// budget on a request that can only end in 409.
if stored, _ := existing["version"].(float64); int(stored) != baseVersion {
respond(w, 0, nil, conflict("Bookmark was modified elsewhere; reload to see the latest version"))
return
}
if s := str(d, "url"); s != "" && s != str(existing, "url") {
addPreview(r.Context(), previewKey(r), d, false)
}
@@ -400,7 +408,7 @@ func HandleBookmarksShare(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
list, e := ids(b, "userIds")
list, e := cappedIDs(b, "userIds", maxShareRecipients)
if e != nil {
respond(w, 0, nil, e)
return
+13 -2
View File
@@ -306,7 +306,7 @@ func HandleCollectionShare(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
list, e := ids(b, "userIds")
list, e := cappedIDs(b, "userIds", maxShareRecipients)
if e != nil {
respond(w, 0, nil, e)
return
@@ -431,7 +431,18 @@ func validatePropertyConfig(typ string, raw any, existing []object) (object, []s
}
return object{"mode": mode}, nil, nil
default:
if m, ok := raw.(object); ok && raw != nil && len(m) > 0 {
// Review M-13: these types take no configuration. Accept null or an
// empty object and reject every other value — silently replacing a
// malformed config with {} told the client it had configured
// something it had not.
if raw == nil {
return object{}, nil, nil
}
m, ok := raw.(object)
if !ok {
return nil, nil, bad("config must be an object")
}
if len(m) > 0 {
return nil, nil, bad(typ + " properties take no config")
}
return object{}, nil, nil
+53 -7
View File
@@ -13,6 +13,7 @@ import (
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
@@ -36,7 +37,19 @@ func read(w http.ResponseWriter, r *http.Request) (object, bool) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var b object
dec := json.NewDecoder(r.Body)
if dec.Decode(&b) != nil || b == nil {
if err := dec.Decode(&b); err != nil {
// Review L-8: an oversized body is 413, not malformed JSON. The Next
// proxy already answers 413; direct API callers deserve the same
// class of error.
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeJSON(w, 413, Error{"Request body too large (limit 1 MiB)"})
return nil, false
}
writeJSON(w, 400, Error{"Invalid JSON object"})
return nil, false
}
if b == nil {
writeJSON(w, 400, Error{"Invalid JSON object"})
return nil, false
}
@@ -98,6 +111,27 @@ func ids(b object, k string) ([]string, error) {
}
return out, nil
}
// Share-list caps (review M-1). A 1 MiB JSON body can carry tens of
// thousands of ids; each one used to become an INSERT in a user-locked
// transaction. These are product limits, not storage limits.
const (
maxShareRecipients = 100
maxSharedDateRecipients = 200
)
// cappedIDs is ids plus a length cap (review M-1), returning a clear 400.
func cappedIDs(b object, key string, max int) ([]string, error) {
list, e := ids(b, key)
if e != nil {
return nil, e
}
if len(list) > max {
return nil, bad(fmt.Sprintf("%s may contain at most %d entries", key, max))
}
return list, nil
}
func fields(b object, allowed string) object {
out := object{}
for _, k := range strings.Fields(allowed) {
@@ -254,11 +288,23 @@ func reorder(r *http.Request, table, col string, order []string) error {
return nil
})
}
func shareRows(r *http.Request, tx *sql.Tx, table, key, id string, list []string) error {
for _, u := range list {
if _, e := tx.ExecContext(r.Context(), `INSERT INTO `+db.Quote(table)+` (`+db.Quote(key)+`,"userId") VALUES($1,$2) ON CONFLICT DO NOTHING`, id, u); e != nil {
return e
}
// outboundBusy answers 429 with Retry-After when the shared outbound budget
// is exhausted (review M-2).
func outboundBusy(w http.ResponseWriter, retry time.Duration) {
if retry < time.Second {
retry = time.Second
}
return nil
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
writeJSON(w, 429, Error{"Too much outbound work right now; try again shortly"})
}
// shareRows inserts every recipient in ONE statement (review M-1). table and
// key come only from the server-side call sites, never from request input.
func shareRows(r *http.Request, tx *sql.Tx, table, key, id string, list []string) error {
if len(list) == 0 {
return nil
}
_, e := tx.ExecContext(r.Context(), `INSERT INTO `+db.Quote(table)+` (`+db.Quote(key)+`,"userId") SELECT $1,unnest($2::text[]) ON CONFLICT DO NOTHING`, id, list)
return e
}
+31 -2
View File
@@ -230,16 +230,32 @@ func HandleKanbanColumnsDelete(w http.ResponseWriter, r *http.Request) {
return bad("A board needs at least one column")
}
dest = str(remaining[0], "id")
// The destination column's legacy key, when it has one, becomes the
// moved cards' kanbanStatus so the two representations do not point
// at the deleted column (review M-5/L-12). Custom columns have no
// key; their cards keep the legacy status as a documented fallback.
destKey := ""
for _, c := range remaining {
if str(c, "id") == dest {
if k := str(c, "key"); validStatus(k) {
destKey = k
}
}
}
// Only the three columns ResolveColumnID needs, and one set-based
// UPDATE for the whole move instead of a round trip per bookmark.
bms, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('id',b.id,'kanbanColumnId',b."kanbanColumnId",'kanbanStatus',b."kanbanStatus") FROM "Bookmark" b WHERE "ownerId"=$1`, user(r))
// Positions are normalized below too (review M-5), so reload order is
// deterministic instead of interleaving with the destination's cards.
bms, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('id',b.id,'kanbanColumnId',b."kanbanColumnId",'kanbanStatus',b."kanbanStatus") FROM "Bookmark" b WHERE "ownerId"=$1 ORDER BY b."kanbanPosition",b."updatedAt" DESC,b.id DESC`, user(r))
if e != nil {
return e
}
moveIDs := make([]string, 0, len(bms))
moving := map[string]bool{}
for _, b := range bms {
if kanban.ResolveColumnID(b, cols) == id {
moveIDs = append(moveIDs, str(b, "id"))
moving[str(b, "id")] = true
}
}
if len(moveIDs) > 0 {
@@ -248,7 +264,20 @@ func HandleKanbanColumnsDelete(w http.ResponseWriter, r *http.Request) {
// snapshot that still shows the old column into a 409 on its
// next save instead of letting it silently move the bookmark
// back.
if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" SET "kanbanColumnId"=$1,version=version+1,"updatedAt"=now() WHERE id=ANY($2::text[])`, dest, moveIDs); e != nil {
if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" SET "kanbanColumnId"=$1,"kanbanStatus"=CASE WHEN $4='' THEN "kanbanStatus" ELSE $4::"KanbanStatus" END,version=version+1,"updatedAt"=now() WHERE id=ANY($2::text[]) AND "ownerId"=$3`, dest, moveIDs, user(r), destKey); e != nil {
return e
}
// Append the moved cards after the destination's existing order
// and renumber contiguously, exactly like a normal card move.
order := make([]string, 0, len(bms))
for _, b := range bms {
bid := str(b, "id")
if !moving[bid] && kanban.ResolveColumnID(b, remaining) == dest {
order = append(order, bid)
}
}
order = append(order, moveIDs...)
if _, e := tx.ExecContext(r.Context(), `UPDATE "Bookmark" AS b SET "kanbanPosition"=t.ord-1 FROM unnest($1::text[]) WITH ORDINALITY AS t(bid,ord) WHERE b.id=t.bid AND b."ownerId"=$2`, order, user(r)); e != nil {
return e
}
moved = len(moveIDs)
+55 -4
View File
@@ -9,8 +9,10 @@ import (
crypt "github.com/andrew/go-computer/internal/crypto"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/netguard"
"github.com/andrew/go-computer/internal/outbound"
"github.com/andrew/go-computer/internal/signals"
"io"
"net"
"net/http"
"net/url"
"strconv"
@@ -18,12 +20,24 @@ import (
"time"
)
// carddavDial pins production CardDAV traffic to 443 (review M-3), matching
// previews (80/443) and IMAP (143/993). Without it a user could make the
// server TLS-handshake and send Basic-auth credentials to any public port.
// The fixture/LAN escape hatch keeps loopback fakes and deliberate private
// deployments working.
func carddavDial(ctx context.Context, network, address string) (net.Conn, error) {
if netguard.AllowPrivate() {
return netguard.DialContext(ctx, network, address)
}
return netguard.DialContextPorts(ctx, network, address, 443)
}
// carddavClient dials through netguard so a user-supplied Nextcloud URL can
// never point the server at internal infrastructure (same guard as URL
// previews). The transport is shared to avoid leaking idle sockets per sync.
var carddavClient = &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{DialContext: netguard.DialContext},
Transport: &http.Transport{DialContext: carddavDial},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
@@ -65,7 +79,7 @@ func HandleSharedDatesCreate(w http.ResponseWriter, r *http.Request) {
respond(w, 0, nil, bad("Date required"))
return
}
list, e := ids(b, "recipientIds")
list, e := cappedIDs(b, "recipientIds", maxSharedDateRecipients)
if e != nil || len(list) == 0 {
respond(w, 0, nil, bad("Recipients required"))
return
@@ -81,7 +95,11 @@ func HandleSharedDatesCreate(w http.ResponseWriter, r *http.Request) {
out := []object{}
e = db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
for _, id := range list {
v, e := db.Insert(r.Context(), tx, "SharedDate", object{"id": db.NewID(), "date": dt, "creatorId": user(r), "recipientId": id, "note": b["note"]})
// Review L-3: re-sharing the same creator/recipient/date updates
// the note instead of inserting a duplicate calendar row.
v, e := db.One(r.Context(), tx, `INSERT INTO "SharedDate"(id,date,note,"creatorId","recipientId") VALUES($1,$2,$3,$4,$5)
ON CONFLICT ("creatorId","recipientId",date) DO UPDATE SET note=EXCLUDED.note
RETURNING to_jsonb("SharedDate")`, db.NewID(), dt, b["note"], user(r), id)
if e != nil {
return e
}
@@ -149,6 +167,13 @@ func HandleContactsSync(w http.ResponseWriter, r *http.Request) {
respond(w, 0, nil, bad("Nextcloud URL must use https"))
return
}
// Review M-3: the transport pins production dials to 443; reject an
// explicit non-standard port up front for a clear error instead of a
// generic connection failure.
if !netguard.AllowPrivate() && base.Port() != "" && base.Port() != "443" {
respond(w, 0, nil, bad("Nextcloud URL must use port 443"))
return
}
path := str(b, "cardDavPath")
if path == "" {
path = "/remote.php/dav/addressbooks/users/" + url.PathEscape(str(b, "username")) + "/contacts/"
@@ -158,6 +183,15 @@ func HandleContactsSync(w http.ResponseWriter, r *http.Request) {
respond(w, 0, nil, e)
return
}
// Review M-2: CardDAV sync draws from the shared outbound budget with
// IMAP/preview/signal checks, so one user cannot open unbounded
// long-lived connections or starve egress.
release, retry, ok := outbound.Reserve("carddav:"+user(r), 10, time.Minute)
if !ok {
outboundBusy(w, retry)
return
}
defer release()
payload := `<?xml version="1.0"?><card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><card:address-data/></d:prop></card:addressbook-query>`
req, e := http.NewRequestWithContext(r.Context(), "REPORT", target.String(), strings.NewReader(payload))
if e != nil {
@@ -493,11 +527,20 @@ func HandleImapTest(w http.ResponseWriter, r *http.Request) {
}
m.Password = saved.Password
}
release, retry, ok := outbound.Reserve("imap:"+user(r), 10, time.Minute)
if !ok {
outboundBusy(w, retry)
return
}
defer release()
c, e := signals.Connect(r.Context(), m)
if e != nil {
respond(w, 0, nil, bad("Mailbox connection or login failed"))
return
}
// Review M-2: a hostile server must not hold the handler open during the
// goodbye exchange after the test has already succeeded.
c.Timeout = 5 * time.Second
c.Logout()
respond(w, 200, OK{true}, nil)
}
@@ -523,6 +566,12 @@ func HandleImapCheck(w http.ResponseWriter, r *http.Request) {
respond(w, 0, nil, bad("Configure IMAP first"))
return
}
release, retry, ok := outbound.Reserve("imap:"+user(r), 10, time.Minute)
if !ok {
outboundBusy(w, retry)
return
}
defer release()
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
defer cancel()
c, e := signals.Connect(ctx, m)
@@ -532,7 +581,9 @@ func HandleImapCheck(w http.ResponseWriter, r *http.Request) {
}
stop := context.AfterFunc(ctx, func() { c.Terminate() })
defer stop()
defer c.Logout()
// Keep the goodbye exchange bounded too (review M-2); searches above
// stay under the caller's 45-second context.
defer func() { c.Timeout = 5 * time.Second; c.Logout() }()
searchFailed := false
for _, b := range watched {
events, e := signals.SearchConn(c, m, "", str(b, "imapQuery"))
+47 -1
View File
@@ -131,6 +131,52 @@ func HandlePagesCreate(w http.ResponseWriter, r *http.Request) {
})
respond(w, 201, v, e)
}
// reorderPages enforces the pinned/unpinned grouping invariant server-side
// (review M-9). The list orders by pinned DESC, position, so a permutation
// that interleaves the groups could never be displayed as requested. Rather
// than mutating meaningless positions, positions are normalized INSIDE each
// group: the submitted order determines the order of pinned pages among
// pinned pages and unpinned among unpinned, regardless of interleaving.
func reorderPages(r *http.Request, order []string) error {
return db.Transaction(r.Context(), database(r), func(tx *sql.Tx) error {
if e := lockUser(r, tx); e != nil {
return e
}
rows, e := db.Many(r.Context(), tx, `SELECT jsonb_build_object('id',id,'pinned',pinned) FROM "Page" WHERE "ownerId"=$1`, user(r))
if e != nil {
return e
}
if len(rows) != len(order) {
return bad("Order must contain every record exactly once")
}
pinnedByID := make(map[string]bool, len(rows))
for _, row := range rows {
isPinned, _ := row["pinned"].(bool)
pinnedByID[str(row, "id")] = isPinned
}
groups := map[bool][]string{}
seen := make(map[string]bool, len(order))
for _, id := range order {
isPinned, ok := pinnedByID[id]
if !ok || seen[id] {
return bad("Order must contain every record exactly once")
}
seen[id] = true
groups[isPinned] = append(groups[isPinned], id)
}
for _, ids := range [][]string{groups[true], groups[false]} {
if len(ids) == 0 {
continue
}
if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" AS p SET position=t.ord-1 FROM unnest($1::text[]) WITH ORDINALITY AS t(pid,ord) WHERE p.id=t.pid AND p."ownerId"=$2`, ids, user(r)); e != nil {
return e
}
}
return nil
})
}
func HandlePagesReorder(w http.ResponseWriter, r *http.Request) {
b, ok := read(w, r)
if !ok {
@@ -138,7 +184,7 @@ func HandlePagesReorder(w http.ResponseWriter, r *http.Request) {
}
list, e := ids(b, "order")
if e == nil {
e = reorder(r, "Page", "ownerId", list)
e = reorderPages(r, list)
}
respond(w, 200, OK{true}, e)
}
+10
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/outbound"
"github.com/andrew/go-computer/internal/signals"
"net/http"
"strings"
@@ -42,6 +43,15 @@ func HandleSignalsCreate(w http.ResponseWriter, r *http.Request) {
// on different connections and leak the lock) — and answer 409 while
// it is held. The 45-second cap keeps the pass inside the server's
// WriteTimeout so it cannot be cut off mid-mutation.
// Review M-2: a manual check is a 45-second outbound pass; it draws
// from the same budget as previews/IMAP/CardDAV so repeated clicks
// cannot multiply concurrent mailbox connections.
release, retry, ok := outbound.Reserve("signals:"+user(r), 10, time.Minute)
if !ok {
outboundBusy(w, retry)
return
}
defer release()
ctx := r.Context()
leaseKey := "signal-poll:" + user(r)
conn, e := database(r).Conn(ctx)
+1 -1
View File
@@ -74,7 +74,7 @@ func HandleTagsUpdate(w http.ResponseWriter, r *http.Request) {
respond(w, 0, nil, e)
return
}
list, e := ids(b, "shareUserIds")
list, e := cappedIDs(b, "shareUserIds", maxShareRecipients)
if e != nil {
respond(w, 0, nil, e)
return