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
+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"))