Address code review findings across API, web and deployment
release gate / check (push) Failing after 6m43s
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user