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
+23 -1
View File
@@ -40,7 +40,13 @@ func failure(w http.ResponseWriter, status int, s string) {
}
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
if json.NewDecoder(r.Body).Decode(v) != nil {
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
// Review L-8: oversized is 413, not malformed JSON.
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
failure(w, 413, "Request body too large (limit 1 MiB)")
return false
}
failure(w, 400, "Invalid JSON")
return false
}
@@ -69,6 +75,9 @@ func HandleSignup(w http.ResponseWriter, r *http.Request) {
failure(w, 400, "Invalid email")
return
}
// Review L-2: a display name of only whitespace is not a name. Trim, then
// fall back to the email local-part when nothing remains.
req.Name = strings.TrimSpace(req.Name)
if len([]rune(req.Name)) > maxSignupNameLen {
failure(w, 400, "Name must be at most 120 characters")
return
@@ -144,6 +153,13 @@ func checkAuthRequest(w http.ResponseWriter, r *http.Request) bool {
failure(w, 415, "Content-Type must be application/json")
return false
}
return checkOrigin(w, r)
}
// checkOrigin rejects a cross-origin Origin when one is present. Extracted
// from checkAuthRequest so the sign-out endpoint can share the same guard
// without forcing a JSON content type on legacy cookie clients (review M-16).
func checkOrigin(w http.ResponseWriter, r *http.Request) bool {
if origin := r.Header.Get("Origin"); origin != "" {
allowed, err := url.Parse(config.Load().NextAuthURL)
scheme := "http"
@@ -236,6 +252,12 @@ func HandleAuth(w http.ResponseWriter, r *http.Request) {
failure(w, 405, "Use POST to sign out")
return
}
// Review M-16: keep sign-out consistent with the rest of the auth
// surface. A browser cross-site POST is rejected; idempotent calls
// for an already-invalid session still succeed below.
if !checkOrigin(w, r) {
return
}
// Revoke every cookie JWT issued for this user, not just this
// browser's copy: signout means "sign out everywhere".
if su, e := ReadSession(r, config.Load().Secret()); e == nil {
+21 -15
View File
@@ -35,13 +35,13 @@ import (
)
const (
loginIPLimit = 20 // logins per IP per window
loginIPWindow = time.Minute //
signupIPLimit = 10 // signups per IP per window
signupIPWindow = time.Hour //
failureThreshold = 5 // consecutive failures before lockout
failureLockout = 15 * time.Minute
failureWindow = time.Hour // failures older than this are forgotten
loginIPLimit = 20 // logins per IP per window
loginIPWindow = time.Minute //
signupIPLimit = 10 // signups per IP per window
signupIPWindow = time.Hour //
failureThreshold = 5 // consecutive failures before lockout
failureLockout = 15 * time.Minute
failureWindow = time.Hour // failures older than this are forgotten
// limiterMaxEntries is the soft cap per store. Review SEC-03: the failure
// tracker previously never evicted sub-threshold entries (zero until), so
@@ -169,9 +169,6 @@ func ResetRateLimits() {
}
}
// resetRateLimits clears all limiter state. Tests only.
func resetRateLimits() { ResetRateLimits() }
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
@@ -285,14 +282,23 @@ func RateLimitSignup(next http.HandlerFunc) http.HandlerFunc {
// callers (e.g. public endpoints) can have independent buckets.
func NewLimiterStore() *limiterStore { return newLimiterStore() }
// Limiter wraps a handler with a simple per-IP fixed-window rate limit.
// Uses the direct peer IP (no forwarded headers) and does not exempt
// loopback — public endpoints apply to every peer.
// Limiter wraps a handler with a simple per-client fixed-window rate limit.
// Review H-2: this buckets on EffectiveClientIP (the signed forwarded browser
// identity when the trusted Next server attested one), not the raw direct
// peer. Behind the Next proxy every public request otherwise shared one
// instance-wide bucket, so a single caller could deny the endpoint to all
// users. Loopback is not exempt — public endpoints apply to every peer.
func Limiter(s *limiterStore, limit int, window time.Duration) func(http.HandlerFunc) http.HandlerFunc {
return LimiterKeyed(s, limit, window, EffectiveClientIP)
}
// LimiterKeyed is Limiter with a caller-supplied bucket key. It backs the
// second public-share layer (review H-2): one hot bookmark id gets its own
// generous budget in addition to the per-client one.
func LimiterKeyed(s *limiterStore, limit int, window time.Duration, key func(*http.Request) string) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
if ok, retry := s.allow(ip, limit, window, time.Now()); !ok {
if ok, retry := s.allow(key(r), limit, window, time.Now()); !ok {
tooManyRequests(w, retry)
return
}
+45 -7
View File
@@ -93,8 +93,8 @@ func TestFailureTrackerEviction(t *testing.T) {
func dummyHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }
func TestRateLimitMiddleware(t *testing.T) {
resetRateLimits()
t.Cleanup(resetRateLimits)
ResetRateLimits()
t.Cleanup(ResetRateLimits)
// Review SEC-1: loopback is NOT exempt on the login endpoint — same-host
// deployments must keep throttling for real browser logins.
@@ -109,7 +109,7 @@ func TestRateLimitMiddleware(t *testing.T) {
t.Fatalf("loopback login exempt: got %d", loopLast.Code)
}
// Signup keeps the loopback exemption for host-local consumers.
resetRateLimits()
ResetRateLimits()
var loopSignup *httptest.ResponseRecorder
for i := 0; i < signupIPLimit*2; i++ {
loopSignup = httptest.NewRecorder()
@@ -118,7 +118,7 @@ func TestRateLimitMiddleware(t *testing.T) {
if loopSignup.Code != 204 {
t.Fatalf("loopback signup limited with %d", loopSignup.Code)
}
resetRateLimits()
ResetRateLimits()
// Non-loopback peers get 429 after the window limit, with Retry-After.
remote := httptest.NewRequest("POST", "/api/auth/login", nil)
@@ -136,7 +136,7 @@ func TestRateLimitMiddleware(t *testing.T) {
}
// Signup limiter counts independently.
resetRateLimits()
ResetRateLimits()
var signupLast *httptest.ResponseRecorder
for i := 0; i < signupIPLimit+1; i++ {
signupLast = httptest.NewRecorder()
@@ -207,10 +207,48 @@ func TestSignedForwardedClientIP(t *testing.T) {
}
}
// Review H-2: public endpoints must bucket on the signed forwarded browser
// identity too, not the shared Next container peer.
func TestPublicLimiterUsesForwardedIP(t *testing.T) {
t.Setenv("NEXTAUTH_SECRET", "")
t.Setenv("AUTH_SECRET", "public-limiter-forward-secret")
ts := time.Now().Unix()
sign := func(ip string) (string, string) {
mac := hmac.New(sha256.New, []byte("public-limiter-forward-secret"))
mac.Write([]byte(ip + "|" + strconv.FormatInt(ts, 10)))
return strconv.FormatInt(ts, 10), hex.EncodeToString(mac.Sum(nil))
}
handler := Limiter(NewLimiterStore(), 2, time.Minute)(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
do := func(ip string) int {
r := httptest.NewRequest("GET", "/api/public/bookmarks/x", nil)
r.RemoteAddr = "10.0.0.5:1234"
hts, sig := sign(ip)
r.Header.Set(headerClientIP, ip)
r.Header.Set(headerClientTs, hts)
r.Header.Set(headerClientSig, sig)
w := httptest.NewRecorder()
handler(w, r)
return w.Code
}
for i := 0; i < 2; i++ {
if code := do("198.51.100.1"); code != http.StatusOK {
t.Fatalf("browser 1 request %d: %d", i+1, code)
}
}
if code := do("198.51.100.1"); code != http.StatusTooManyRequests {
t.Fatalf("browser 1 over limit: %d", code)
}
if code := do("198.51.100.2"); code != http.StatusOK {
t.Fatalf("browser 2 blocked by browser 1: %d", code)
}
}
func TestForwardedIPsGetSeparateBuckets(t *testing.T) {
t.Setenv("NEXTAUTH_SECRET", "")
t.Setenv("AUTH_SECRET", "ratelimit-forward-secret")
resetRateLimits()
ResetRateLimits()
ts := time.Now().Unix()
sign := func(ip string) (string, string) {
mac := hmac.New(sha256.New, []byte("ratelimit-forward-secret"))
@@ -241,5 +279,5 @@ func TestForwardedIPsGetSeparateBuckets(t *testing.T) {
if code := do("198.51.100.2"); code != 200 {
t.Fatalf("browser 2 blocked by browser 1: %d", code)
}
resetRateLimits()
ResetRateLimits()
}
+44 -12
View File
@@ -1,7 +1,9 @@
package authguard
import (
"database/sql"
"encoding/json"
"errors"
"github.com/andrew/go-computer/internal/auth"
"github.com/andrew/go-computer/internal/config"
"github.com/andrew/go-computer/internal/db"
@@ -11,6 +13,11 @@ import (
"time"
)
// errLastAdmin is the transactional refusal to remove the final
// administrator (review H-4). It is raised inside the locked transaction so
// the count and the update cannot be interleaved by a concurrent demotion.
var errLastAdmin = errors.New("at least one administrator must remain")
func HandleAdminListUsers(w http.ResponseWriter, r *http.Request) {
// COALESCE(email,'') (review COR-8): NOT LIKE evaluates to NULL for the
// NULL emails OIDC-sourced accounts can carry, which hid them from the
@@ -43,21 +50,46 @@ func HandleAdminPatchUser(w http.ResponseWriter, r *http.Request) {
handlers.WriteJSON(w, 404, handlers.Error{Error: "Not found"})
return
}
// Review SEC-11: an installation must keep at least one administrator.
// Review SEC-11/H-4: an installation must keep at least one administrator.
// Self-demotion is already refused; this closes the "demote the last
// other admin" path.
// other admin" path. The role re-read, count and update all run in ONE
// transaction serialized by an advisory lock, so two admins demoting
// each other concurrently can no longer both observe admins == 2 and
// leave zero. The pre-transaction role read above only picks the audit
// message; the decision uses the locked value.
if b.Role == "USER" && target.Role == "ADMIN" {
var admins int
if e := d.QueryRowContext(r.Context(), `SELECT count(*) FROM "User" WHERE role='ADMIN' AND COALESCE(email,'') NOT LIKE '%@example.com'`).Scan(&admins); e != nil {
handlers.WriteJSON(w, 500, handlers.Error{Error: "Unable to change role"})
return
}
if admins <= 1 {
handlers.WriteJSON(w, 400, handlers.Error{Error: "At least one administrator must remain"})
return
}
e = db.Transaction(r.Context(), d, func(tx *sql.Tx) error {
if _, e := tx.ExecContext(r.Context(), `SELECT pg_advisory_xact_lock(927439)`); e != nil {
return e
}
var current string
if e := tx.QueryRowContext(r.Context(), `SELECT role FROM "User" WHERE id=$1`, b.UserID).Scan(&current); e != nil {
return e
}
if current == "ADMIN" {
var admins int
if e := tx.QueryRowContext(r.Context(), `SELECT count(*) FROM "User" WHERE role='ADMIN' AND COALESCE(email,'') NOT LIKE '%@example.com'`).Scan(&admins); e != nil {
return e
}
if admins <= 1 {
return errLastAdmin
}
}
_, e := tx.ExecContext(r.Context(), `UPDATE "User" SET role=$1,"updatedAt"=now() WHERE id=$2`, b.Role, b.UserID)
return e
})
} else {
e = repo.SetRole(r.Context(), b.UserID, b.Role)
}
if e := repo.SetRole(r.Context(), b.UserID, b.Role); e != nil {
if errors.Is(e, sql.ErrNoRows) {
handlers.WriteJSON(w, 404, handlers.Error{Error: "Not found"})
return
}
if errors.Is(e, errLastAdmin) {
handlers.WriteJSON(w, 400, handlers.Error{Error: "At least one administrator must remain"})
return
}
if e != nil {
handlers.WriteJSON(w, 500, handlers.Error{Error: "Unable to change role"})
return
}
+32 -2
View File
@@ -1,9 +1,11 @@
package config
import (
"fmt"
"net/url"
"os"
"strings"
"sync"
)
// DBURL returns the database connection string, preferring DATABASE_URL and
@@ -76,11 +78,23 @@ func (c *Config) CookieSecure() bool {
return err == nil && u.Scheme == "https"
}
// adminEmailsCache memoizes the parsed ADMIN_EMAILS value by its raw env
// string (review M-12: IsAdminEmail runs on the auth hot path and re-split
// and re-lowercased the list on every call). Keying on the raw value keeps
// t.Setenv-based tests correct without a process-global startup snapshot.
var adminEmailsCache struct {
mu sync.Mutex
raw string
out []string
}
// AdminEmails splits env ADMIN_EMAILS on commas, trims, lowercases.
func AdminEmails() []string {
s := os.Getenv("ADMIN_EMAILS")
if s == "" {
return nil
adminEmailsCache.mu.Lock()
defer adminEmailsCache.mu.Unlock()
if adminEmailsCache.raw == s {
return adminEmailsCache.out
}
var out []string
for _, email := range strings.Split(s, ",") {
@@ -88,6 +102,7 @@ func AdminEmails() []string {
out = append(out, email)
}
}
adminEmailsCache.raw, adminEmailsCache.out = s, out
return out
}
@@ -111,3 +126,18 @@ func IsAdminEmail(email string) bool {
func AllowBootstrapSignup() bool {
return os.Getenv("ALLOW_BOOTSTRAP_SIGNUP") != "0"
}
// Validate fails fast on configuration the app cannot use, instead of
// discovering it on the first request (review M-12).
func (c *Config) Validate() error {
if strings.TrimSpace(c.Secret()) == "" {
return fmt.Errorf("AUTH_SECRET (or NEXTAUTH_SECRET) must be set")
}
if c.NextAuthURL != "" {
u, err := url.Parse(c.NextAuthURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("NEXTAUTH_URL must be an absolute http(s) URL, got %q", c.NextAuthURL)
}
}
return nil
}
+31
View File
@@ -35,6 +35,37 @@ func TestSecretPrecedence(t *testing.T) {
}
}
// Review M-12: fail fast on unusable configuration at startup.
func TestValidate(t *testing.T) {
if err := (&Config{}).Validate(); err == nil {
t.Fatal("missing secret passed validation")
}
if err := (&Config{AuthSecret: "secret", NextAuthURL: "not a url"}).Validate(); err == nil {
t.Fatal("relative NEXTAUTH_URL passed validation")
}
for _, u := range []string{"", "http://localhost:3000", "https://app.example.com"} {
if err := (&Config{AuthSecret: "secret", NextAuthURL: u}).Validate(); err != nil {
t.Fatalf("valid configuration %q rejected: %v", u, err)
}
}
}
// Review M-12: the parsed ADMIN_EMAILS list is memoized but still follows
// env changes (tests and operator restarts rely on that).
func TestAdminEmailsTracksEnv(t *testing.T) {
t.Setenv("ADMIN_EMAILS", " A@Example.com ,, b@example.com ")
if got := AdminEmails(); len(got) != 2 || got[0] != "a@example.com" || got[1] != "b@example.com" {
t.Fatalf("unexpected parse: %v", got)
}
t.Setenv("ADMIN_EMAILS", "")
if got := AdminEmails(); len(got) != 0 {
t.Fatalf("cache did not follow the env change: %v", got)
}
if IsAdminEmail("a@example.com") {
t.Fatal("stale admin email after env change")
}
}
// Review OPS-01: a dedicated ENCRYPTION_KEY decouples integration-credential
// encryption from the session signing secret.
func TestEncryptionSecretPrecedence(t *testing.T) {
+4 -1
View File
@@ -23,7 +23,10 @@ const BookmarkOwnerView = `to_jsonb(b) || jsonb_build_object(
// readers: the link itself, its presentation metadata, the content the owner
// attached for readers (description, notes, due date, tags), and enough owner
// identity to attribute the share inside the workspace. No contact record, no
// recipient list, no alerts, no mailbox-watch settings, no board placement.
// recipient list, no alerts, no mailbox-watch settings, no board placement
// (kanbanColumnId/kanbanPosition). The legacy kanbanStatus badge IS included
// deliberately: it is status, not placement, and API.md/web/AGENTS.md say so
// (review L-10).
const BookmarkSharedView = `jsonb_build_object(
'id',b.id,'url',b.url,'title',b.title,'description',b.description,'notes',b.notes,
'favicon',b.favicon,'ogImage',b."ogImage",'ogTitle',b."ogTitle",'ogDescription',b."ogDescription",
+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
+332
View File
@@ -0,0 +1,332 @@
package http
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"strings"
"sync"
"testing"
)
// rawCall issues a request with the client's cookies and explicit headers,
// without the apiClient helper's automatic status assertion.
func rawCall(t *testing.T, c apiClient, method, path string, body []byte, headers map[string]string) (int, []byte) {
t.Helper()
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequest(method, c.base+path, r)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
res, err := c.c.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
return res.StatusCode, out
}
// Review H-4: two admins demoting each other must not both pass the
// "at least one administrator" check. The advisory-locked transaction makes
// one of them win and the other observe the committed count.
func TestConcurrentAdminDemotionsKeepOneAdmin(t *testing.T) {
srv, d := testServerDB(t)
a, _ := accountClient(t, srv.URL, "admin-a@computer.test")
b, bID := accountClient(t, srv.URL, "admin-b@computer.test")
a.request("PATCH", "/api/admin/users", map[string]any{"userId": bID, "role": "ADMIN"}, 200)
aID := meID(a)
demote := func(c apiClient, target string) int {
body, _ := json.Marshal(map[string]any{"userId": target, "role": "USER"})
status, _ := rawCall(t, c, "PATCH", "/api/admin/users", body, nil)
return status
}
start := make(chan struct{})
var wg sync.WaitGroup
statuses := make([]int, 2)
wg.Add(2)
go func() { defer wg.Done(); <-start; statuses[0] = demote(a, bID) }()
go func() { defer wg.Done(); <-start; statuses[1] = demote(b, aID) }()
close(start)
wg.Wait()
ok, refused := 0, 0
for _, s := range statuses {
switch s {
case 200:
ok++
case 400, 403:
// 400: still an admin, but the locked count now shows one.
// 403: the other demotion committed first and this session is no
// longer an admin by the time its request is authorized.
refused++
default:
t.Fatalf("unexpected demotion status: %v", statuses)
}
}
if ok != 1 || refused != 1 {
t.Fatalf("wanted one successful and one refused demotion, got %v", statuses)
}
var admins int
if err := d.QueryRow(`SELECT count(*) FROM "User" WHERE role='ADMIN'`).Scan(&admins); err != nil {
t.Fatal(err)
}
if admins != 1 {
t.Fatalf("concurrent demotions left %d administrators", admins)
}
}
// Review L-2: a whitespace-only display name is trimmed and falls back to the
// email local-part instead of being stored verbatim.
func TestSignupTrimsWhitespaceName(t *testing.T) {
srv, _ := testServerDB(t)
jar, _ := cookiejar.New(nil)
c := apiClient{t, &http.Client{Jar: jar}, srv.URL}
body := map[string]any{"email": "trimmed@computer.test", "password": "test-long-password", "name": " "}
c.request("POST", "/api/signup", body, 201)
c.request("POST", "/api/auth/login", body, 200)
me := c.request("GET", "/api/me", nil, 200)
user := me["user"].(map[string]any)
if user["name"] != "trimmed" {
t.Fatalf("whitespace name was not normalized: %v", user["name"])
}
}
// Review M-1: share recipient arrays are capped before they become one
// INSERT per id inside a user-locked transaction.
func TestShareRecipientListsAreCapped(t *testing.T) {
srv, _ := testServerDB(t)
owner, _ := accountClient(t, srv.URL, "cap-owner@computer.test")
id := owner.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.com/cap"}, 201)["id"].(string)
tooMany := make([]string, maxTestShareRecipients+1)
for i := range tooMany {
tooMany[i] = fmt.Sprintf("user-%d", i)
}
owner.request("POST", "/api/bookmarks/"+id+"/share", map[string]any{"userIds": tooMany}, 400)
dates := make([]string, maxTestSharedDateRecipients+1)
for i := range dates {
dates[i] = fmt.Sprintf("user-%d", i)
}
owner.request("POST", "/api/shared-dates", map[string]any{"date": "2026-12-01", "recipientIds": dates}, 400)
}
// Mirrors handlers.maxShareRecipients / maxSharedDateRecipients (unexported).
const (
maxTestShareRecipients = 100
maxTestSharedDateRecipients = 200
)
// Review L-8: an oversized direct API body is 413, not a misleading 400.
func TestOversizedDirectBodyReturns413(t *testing.T) {
srv, _ := testServerDB(t)
owner, _ := accountClient(t, srv.URL, "oversized@computer.test")
body := []byte(`{"url":"https://example.com","notes":"` + strings.Repeat("a", 1<<20) + `"}`)
status, _ := rawCall(t, owner, "POST", "/api/bookmarks", body, nil)
if status != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized body: wanted 413, got %d", status)
}
}
// Review M-16: sign-out rejects a cross-site Origin like the other auth
// endpoints, while an idempotent same-origin call still succeeds.
func TestSignoutRejectsCrossOrigin(t *testing.T) {
srv, _ := testServerDB(t)
owner, _ := accountClient(t, srv.URL, "signout@computer.test")
body := []byte(`{}`)
status, _ := rawCall(t, owner, "POST", "/api/auth/signout", body, map[string]string{"Origin": "https://attacker.invalid"})
if status != http.StatusForbidden {
t.Fatalf("cross-origin signout: wanted 403, got %d", status)
}
status, _ = rawCall(t, owner, "POST", "/api/auth/signout", body, map[string]string{"Origin": srv.URL})
if status != http.StatusOK {
t.Fatalf("same-origin signout: wanted 200, got %d", status)
}
}
// Review M-9: the server normalizes positions inside the pinned/unpinned
// groups instead of trusting a client to keep them apart.
func TestPageReorderKeepsPinnedGrouping(t *testing.T) {
srv, _ := testServerDB(t)
owner, _ := accountClient(t, srv.URL, "reorder@computer.test")
pinned := owner.request("POST", "/api/pages", map[string]any{"title": "Pinned", "pinned": true}, 201)["id"].(string)
second := owner.request("POST", "/api/pages", map[string]any{"title": "Second"}, 201)["id"].(string)
third := owner.request("POST", "/api/pages", map[string]any{"title": "Third"}, 201)["id"].(string)
// Interleaved request: unpinned pages first, the pinned page last.
owner.request("PUT", "/api/pages", map[string]any{"order": []string{third, second, pinned}}, 200)
res, err := owner.c.Get(srv.URL + "/api/pages")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var list []map[string]any
if err := json.NewDecoder(res.Body).Decode(&list); err != nil {
t.Fatal(err)
}
got := make([]string, 0, len(list))
for _, p := range list {
got = append(got, p["id"].(string))
}
want := []string{pinned, third, second}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("page order after reorder = %v, want %v", got, want)
}
owner.request("PUT", "/api/pages", map[string]any{"order": []string{pinned, second}}, 400)
}
// Review M-13: non-object / non-empty config on types that take no config is
// rejected instead of silently discarded.
func TestPropertyConfigRejectsMalformedValues(t *testing.T) {
srv, _ := testServerDB(t)
owner, _ := accountClient(t, srv.URL, "props@computer.test")
cid := owner.request("POST", "/api/collections", map[string]any{"name": "Config"}, 201)["id"].(string)
for _, cfg := range []any{"x", []any{}, 5, true} {
owner.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Bad", "type": "TEXT", "config": cfg}, 400)
}
owner.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Null", "type": "TEXT", "config": nil}, 201)
owner.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Empty", "type": "TEXT", "config": map[string]any{}}, 201)
}
// Review L-3: re-sharing the same date updates the surviving row instead of
// creating duplicate calendar entries.
func TestSharedDateReshareIsIdempotent(t *testing.T) {
srv, _ := testServerDB(t)
owner, _ := accountClient(t, srv.URL, "dates-owner@computer.test")
_, recipientID := accountClient(t, srv.URL, "dates-recipient@computer.test")
post := func(note string) []map[string]any {
body, _ := json.Marshal(map[string]any{"date": "2026-11-05", "note": note, "recipientIds": []string{recipientID}})
status, raw := rawCall(t, owner, "POST", "/api/shared-dates", body, nil)
if status != 201 {
t.Fatalf("shared-date create: %d %s", status, raw)
}
var rows []map[string]any
if err := json.Unmarshal(raw, &rows); err != nil {
t.Fatal(err)
}
return rows
}
first := post("first")
second := post("second")
if len(first) != 1 || len(second) != 1 {
t.Fatalf("want one returned share, got %d and %d", len(first), len(second))
}
if second[0]["id"] != first[0]["id"] {
t.Fatalf("re-share created a new row: %v vs %v", second[0]["id"], first[0]["id"])
}
if second[0]["note"] != "second" {
t.Fatalf("re-share did not update the note: %v", second[0]["note"])
}
res, err := owner.c.Get(srv.URL + "/api/shared-dates")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var list []map[string]any
if err := json.NewDecoder(res.Body).Decode(&list); err != nil {
t.Fatal(err)
}
count := 0
for _, row := range list {
date, _ := row["date"].(string)
if row["recipientId"] == recipientID && strings.HasPrefix(date, "2026-11-05") {
count++
}
}
if count != 1 {
t.Fatalf("want one shared-date row, got %d", count)
}
}
// Review M-3: production CardDAV dials are pinned to 443.
func TestCardDAVRejectsNonStandardPort(t *testing.T) {
srv, _ := testServerDB(t)
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "0")
owner, _ := accountClient(t, srv.URL, "carddav@computer.test")
v := owner.request("POST", "/api/contacts/sync", map[string]any{
"nextcloudUrl": "https://198.51.100.10:8443",
"username": "user",
"password": "secret",
}, 400)
msg, _ := v["error"].(string)
if !strings.Contains(msg, "443") {
t.Fatalf("expected a port-443 error, got %q", msg)
}
}
// Review M-5: deleting a column appends its cards to the destination and
// renumbers positions contiguously, so reload order is deterministic.
func TestColumnDeleteNormalizesMovedCardPositions(t *testing.T) {
srv, _ := testServerDB(t)
owner, _ := accountClient(t, srv.URL, "columns@computer.test")
var columns []map[string]any
res, err := owner.c.Get(srv.URL + "/api/kanban/columns")
if err != nil {
t.Fatal(err)
}
if err := json.NewDecoder(res.Body).Decode(&columns); err != nil {
t.Fatal(err)
}
res.Body.Close()
if len(columns) < 2 {
t.Fatalf("expected the seeded board, got %d columns", len(columns))
}
first, second := columns[0]["id"].(string), columns[1]["id"].(string)
mk := func(column, status, title string) string {
return owner.request("POST", "/api/bookmarks", map[string]any{
"url": "https://example.com/" + title, "title": title,
"kanbanColumnId": column, "kanbanStatus": status,
}, 201)["id"].(string)
}
mk(second, "TODO", "dest-1")
mk(second, "TODO", "dest-2")
mk(first, "INBOX", "moved-1")
mk(first, "INBOX", "moved-2")
owner.request("DELETE", "/api/kanban/columns/"+first, nil, 200)
res, err = owner.c.Get(srv.URL + "/api/bookmarks?limit=200")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var bookmarks []map[string]any
if err := json.NewDecoder(res.Body).Decode(&bookmarks); err != nil {
t.Fatal(err)
}
positions := map[float64]bool{}
for _, b := range bookmarks {
if b["kanbanColumnId"] != second {
t.Fatalf("bookmark %v was not moved to the destination", b["title"])
}
if b["kanbanStatus"] != "TODO" {
t.Fatalf("legacy status for %v still points at the deleted column: %v", b["title"], b["kanbanStatus"])
}
positions[b["kanbanPosition"].(float64)] = true
}
if len(bookmarks) != 4 || len(positions) != 4 {
t.Fatalf("want four contiguously positioned cards, got %d cards at %d positions", len(bookmarks), len(positions))
}
for i := 0; i < 4; i++ {
if !positions[float64(i)] {
t.Fatalf("positions are not contiguous 0..3: %v", positions)
}
}
}
+15 -6
View File
@@ -142,16 +142,25 @@ func NewServer(cfg *config.Config, database *db.DB) *http.Server {
mux.HandleFunc("DELETE /api/collections/{id}/views/{viewId}", authed(handlers.HandleCollectionViewsDelete))
// 4.10 Public page (no login)
// Review SEC-10: public endpoints get per-IP rate limiting (no auth
// context, so no per-user limit). 30 req/min for public bookmark lookup,
// 20 req/min for preview (heavier outbound work).
// Review SEC-10/H-2: public endpoints get two rate-limit layers keyed on
// the EFFECTIVE client identity (signed forwarded browser IP, else the
// direct peer), never the shared Next container address:
// - per client: 30 public lookups/min, 20 previews/min;
// - per public bookmark id: 120/min, so one hot shared link cannot
// exhaust the instance-wide budget.
// Preview is rate limited AFTER auth: unauthenticated requests must not
// consume the bucket and deny previews to signed-in users.
publicBookmarkLimiter := auth.NewLimiterStore()
publicBookmarkIDLimiter := auth.NewLimiterStore()
previewLimiter := auth.NewLimiterStore()
publicLimit := auth.Limiter(publicBookmarkLimiter, 30, time.Minute)
publicIDLimit := auth.LimiterKeyed(publicBookmarkIDLimiter, 120, time.Minute, func(r *http.Request) string {
return "bookmark:" + r.PathValue("id")
})
previewLimit := auth.Limiter(previewLimiter, 20, time.Minute)
mux.HandleFunc("GET /share/bookmark/{id}", publicLimit(handlers.HandlePublicBookmark))
mux.HandleFunc("GET /api/public/bookmarks/{id}", publicLimit(handlers.HandlePublicBookmark))
mux.HandleFunc("GET /api/preview", previewLimit(authed(handlers.HandlePreview)))
mux.HandleFunc("GET /share/bookmark/{id}", publicLimit(publicIDLimit(handlers.HandlePublicBookmark)))
mux.HandleFunc("GET /api/public/bookmarks/{id}", publicLimit(publicIDLimit(handlers.HandlePublicBookmark)))
mux.HandleFunc("GET /api/preview", authed(previewLimit(handlers.HandlePreview)))
return &http.Server{
Handler: withDB(database, mux),
+16 -1
View File
@@ -43,6 +43,21 @@ func BookmarkDocument(b db.Object) string {
}
return out + "\n" + strings.TrimSpace(text(b, "notes")) + "\n"
}
// linkDestination wraps a URL in a CommonMark angle-bracket destination
// (review L-11). Parentheses (and the whitespace validURL accepts) no longer
// terminate the link, and the two characters that cannot appear unescaped
// inside <...> are percent-encoded.
func linkDestination(raw string) string {
escaped := strings.NewReplacer(
"<", "%3C",
">", "%3E",
"\r", "%0D",
"\n", "%0A",
).Replace(raw)
return "<" + escaped + ">"
}
func PageDocument(p db.Object) string {
out := "# " + text(p, "title") + "\n\n" + strings.TrimSpace(text(p, "content")) + "\n"
if a, ok := p["bookmarks"].([]any); ok && len(a) > 0 {
@@ -60,7 +75,7 @@ func PageDocument(p db.Object) string {
if len(rs) > 160 {
desc = string(rs[:160])
}
out += "- [" + title + "](" + text(b, "url") + ")"
out += "- [" + title + "](" + linkDestination(text(b, "url")) + ")"
if desc != "" {
out += " — " + desc
}
+27
View File
@@ -0,0 +1,27 @@
package markdown
import (
"strings"
"testing"
"github.com/andrew/go-computer/internal/db"
)
// Review L-11: link destinations are wrapped and escaped so parentheses or
// angle brackets in a bookmark URL cannot terminate or extend the link.
func TestPageDocumentEscapesLinkDestinations(t *testing.T) {
out := PageDocument(db.Object{
"title": "Links",
"content": "",
"bookmarks": []any{
map[string]any{"bookmark": map[string]any{
"title": "Odd",
"url": "https://example.com/a(b)?x=<y>",
}},
},
})
want := "[Odd](<https://example.com/a(b)?x=%3Cy%3E>)"
if !strings.Contains(out, want) {
t.Fatalf("link destination not escaped:\n%s", out)
}
}
+5 -2
View File
@@ -22,7 +22,11 @@ import (
// Settings page and the login page read to decide whether to show the
// "Sign in with Authelia" button.
//
// GET /api/auth/oidc/status -> {enabled, issuerSet, clientIdSet, clientSecretSet, issuer}
// GET /api/auth/oidc/status -> {enabled, issuerSet, clientIdSet, clientSecretSet}
//
// Review L-7: the issuer URL is deliberately NOT public. It is usually a
// public hostname, but an internal one would become an information leak; the
// authenticated settings surface can expose it when one exists.
func HandleStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodGet {
@@ -38,7 +42,6 @@ func HandleStatus(w http.ResponseWriter, r *http.Request) {
"issuerSet": s.IssuerSet,
"clientIdSet": s.ClientIDSet,
"clientSecretSet": s.ClientSecretSet,
"issuer": s.Issuer,
})
}
+75
View File
@@ -0,0 +1,75 @@
// Package outbound bounds server-initiated network work — URL previews,
// CardDAV syncs, IMAP test/check passes and manual signal checks — so an
// authenticated user cannot open unbounded outbound connections, exhaust
// file descriptors or saturate egress (review M-2).
//
// Two layers are shared by every caller:
// - a global in-flight cap (defaultConcurrent), and
// - a per-identity fixed-window rate (the caller supplies the limit/window,
// since a preview is cheaper than an IMAP search).
//
// Exhaustion is immediate: previews degrade to no metadata and interactive
// endpoints answer 429 with Retry-After. Nothing queues behind the budget.
package outbound
import (
"sync"
"time"
)
const defaultConcurrent = 8
type window struct {
count int
reset time.Time
}
type budget struct {
mu sync.Mutex
inFlight int
max int
keys map[string]*window
}
var shared = &budget{max: defaultConcurrent, keys: map[string]*window{}}
// Reserve claims one outbound slot for key. The returned release MUST be
// called when the work finishes. When ok is false, retry is a conservative
// Retry-After duration.
func Reserve(key string, limit int, windowDur time.Duration) (release func(), retry time.Duration, ok bool) {
if key == "" {
key = "anonymous"
}
shared.mu.Lock()
defer shared.mu.Unlock()
if shared.inFlight >= shared.max {
return nil, time.Second, false
}
now := time.Now()
if len(shared.keys) > 4096 {
for k, w := range shared.keys {
if now.After(w.reset) {
delete(shared.keys, k)
}
}
}
w := shared.keys[key]
if w == nil || now.After(w.reset) {
w = &window{reset: now.Add(windowDur)}
shared.keys[key] = w
}
if limit > 0 && w.count >= limit {
left := w.reset.Sub(now)
if left < 0 {
left = 0
}
return nil, left.Truncate(time.Second) + time.Second, false
}
w.count++
shared.inFlight++
return func() {
shared.mu.Lock()
shared.inFlight--
shared.mu.Unlock()
}, 0, true
}
+42
View File
@@ -0,0 +1,42 @@
package outbound
import (
"testing"
"time"
)
// Review M-2: outbound work is bounded per identity and globally, and a
// released slot is reusable.
func TestReserveBoundsPerKeyAndGlobally(t *testing.T) {
releaseA, retry, ok := Reserve("test-a", 1, time.Minute)
if !ok || releaseA == nil {
t.Fatalf("first reservation rejected (retry %v)", retry)
}
if _, _, ok := Reserve("test-a", 1, time.Minute); ok {
t.Fatal("per-key limit did not reject the second reservation")
}
// Fill the remaining global slots on distinct keys.
releases := []func(){releaseA}
for i := 0; i < defaultConcurrent-1; i++ {
release, _, ok := Reserve("test-global-"+string(rune('a'+i)), 10, time.Minute)
if !ok {
t.Fatalf("global slot %d rejected below the cap", i)
}
releases = append(releases, release)
}
if _, _, ok := Reserve("test-overflow", 10, time.Minute); ok {
t.Fatal("global concurrency cap did not reject an extra reservation")
}
// Release one slot and verify the global capacity is usable again.
releases[0]()
release, _, ok := Reserve("test-after-release", 1, time.Minute)
if !ok {
t.Fatal("released slot was not reusable")
}
release()
for _, r := range releases[1:] {
r()
}
}
+10 -49
View File
@@ -4,13 +4,13 @@ import (
"context"
"fmt"
"github.com/andrew/go-computer/internal/netguard"
"github.com/andrew/go-computer/internal/outbound"
"golang.org/x/net/html"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
@@ -72,60 +72,21 @@ var previewClient = &http.Client{
// previews are decorative, and a bookmark save must never be coupled to
// preview availability (the row persists without og metadata).
const (
keyLimit = 20
keyWindow = time.Minute
keyLimit = 20
keyWindow = time.Minute
// maxConcurrent mirrors outbound's global cap; kept for the budget tests
// and documentation.
maxConcurrent = 8
)
type rateWindow struct {
count int
reset time.Time
}
type budgetStore struct {
mu sync.Mutex
inFlight int
keys map[string]*rateWindow
}
var sharedBudget = &budgetStore{keys: map[string]*rateWindow{}}
// Reserve claims one preview-work slot for key (the requesting identity).
// The returned release function MUST be called when the fetch finishes.
// ok is false when either budget layer is exhausted.
// ok is false when either budget layer is exhausted. Preview shares the
// process-wide outbound budget with CardDAV/IMAP/signal checks (review M-2),
// while keeping its own more generous per-identity rate.
func Reserve(key string) (release func(), ok bool) {
if key == "" {
key = "anonymous"
}
sharedBudget.mu.Lock()
defer sharedBudget.mu.Unlock()
if sharedBudget.inFlight >= maxConcurrent {
return nil, false
}
now := time.Now()
// Opportunistic prune keeps the map bounded by active identities.
if len(sharedBudget.keys) > 4096 {
for k, w := range sharedBudget.keys {
if now.After(w.reset) {
delete(sharedBudget.keys, k)
}
}
}
w := sharedBudget.keys[key]
if w == nil || now.After(w.reset) {
w = &rateWindow{reset: now.Add(keyWindow)}
sharedBudget.keys[key] = w
}
if w.count >= keyLimit {
return nil, false
}
w.count++
sharedBudget.inFlight++
return func() {
sharedBudget.mu.Lock()
sharedBudget.inFlight--
sharedBudget.mu.Unlock()
}, true
release, _, ok = outbound.Reserve(key, keyLimit, keyWindow)
return release, ok
}
// FetchPreview resolves metadata for raw on behalf of key (the requesting