From aac1725c25642305b1163bddef2c927a6753b4b4 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 20:48:52 -0500 Subject: [PATCH 1/6] #280: remove PALETTE_TRUSTED_IP_HEADER, key rate limits on peer address only Owner follow-up to the #280 fix (PR #284): the trusted-header env var is gone. clientIP() now uses the peer address exclusively and ignores all client-supplied IP headers; the env var row is removed from the README. --- README.md | 1 - internal/api/clientip.go | 36 +++--------------------------- internal/api/ratelimit_xff_test.go | 19 +++++----------- internal/api/server.go | 5 ----- 4 files changed, 9 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 9a57fb9..19707fc 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,6 @@ go build -o palette ./cmd/palette | `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `/admin-key` (0600) | | `PALETTE_DEFAULT_DARK` | dark on | Default dark mode for new visitors. Set `false`, `0`, or `off` to default to light mode. Visitors who toggle dark mode keep their choice in their browser. | | `PALETTE_UNLOCK_SECRET` | random per start | HMAC secret for password-unlock cookies. Set a fixed value to keep unlock sessions across restarts or across replicas. | -| `PALETTE_TRUSTED_IP_HEADER` | unset | Name of a proxy-controlled client-IP header to key API rate limits on (e.g. `CF-Connecting-IP` when Cloudflare is the ingress; Cloudflare strips any client-supplied value). Unset: rate limits key on the peer address only, and all client-supplied IP headers (X-Forwarded-For, X-Real-Ip) are ignored. (#280) | An `/admin` page exists for runtime settings, protected by a key set at install (`PALETTE_ADMIN_KEY` env var) and resettable locally. See diff --git a/internal/api/clientip.go b/internal/api/clientip.go index 82ae13b..b382eaa 100644 --- a/internal/api/clientip.go +++ b/internal/api/clientip.go @@ -7,48 +7,18 @@ // per request and the limit was unenforceable (pentest H1: 6x201 across 8 // rotating-XFF creates). // -// Default: key on the actual peer address (RemoteAddr) only. Behind any +// The bucket key is the actual peer address (RemoteAddr) only. Behind any // reverse proxy this is the proxy's address, so all clients share one bucket -// per endpoint — coarse, but safe. -// -// Proxy-honoring mode: a deployment in front of a proxy that OVERWRITES (not -// appends to) a client-IP header can set PALETTE_TRUSTED_IP_HEADER (e.g. -// CF-Connecting-IP when Cloudflare is the ingress; Cloudflare strips any -// client-supplied value). The header is honored ONLY when explicitly -// configured at startup, and X-Forwarded-For / X-Real-Ip are never trusted. +// per endpoint — coarse, but safe. Client-supplied IP headers +// (X-Forwarded-For, X-Real-Ip) are never trusted. package api import ( "net" "net/http" - "sync" ) -var ( - trustedIPMu sync.RWMutex - trustedIPHeader string // empty = never trust any client-IP header -) - -// SetTrustedIPHeader configures the single proxy-controlled header whose -// value may key rate-limit buckets. Called at startup; tests may reset it. -func SetTrustedIPHeader(name string) { - trustedIPMu.Lock() - defer trustedIPMu.Unlock() - trustedIPHeader = name -} - -func getTrustedIPHeader() string { - trustedIPMu.RLock() - defer trustedIPMu.RUnlock() - return trustedIPHeader -} - func clientIP(r *http.Request) string { - if name := getTrustedIPHeader(); name != "" { - if v := r.Header.Get(name); v != "" { - return v - } - } host := r.RemoteAddr if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { host = h diff --git a/internal/api/ratelimit_xff_test.go b/internal/api/ratelimit_xff_test.go index 680d5d0..3235669 100644 --- a/internal/api/ratelimit_xff_test.go +++ b/internal/api/ratelimit_xff_test.go @@ -7,8 +7,6 @@ import ( ) func TestClientIPUsesRemoteAddrNotXFF(t *testing.T) { - SetTrustedIPHeader("") - defer SetTrustedIPHeader("") r := httptest.NewRequest("POST", "/api/pastes", nil) r.RemoteAddr = "203.0.113.7:4432" r.Header.Set("X-Forwarded-For", "1.2.3.4, 1.2.3.5, 203.0.113.9") @@ -18,18 +16,15 @@ func TestClientIPUsesRemoteAddrNotXFF(t *testing.T) { } } -func TestClientIPTrustedHeaderOnlyWhenConfigured(t *testing.T) { - SetTrustedIPHeader("") - defer SetTrustedIPHeader("") +// A proxy-controlled header is not honored even when set: #280 revision +// removed the PALETTE_TRUSTED_IP_HEADER mechanism per owner decision, so the +// bucket key is the peer address only. +func TestClientIPNeverTrustsHeaders(t *testing.T) { r := httptest.NewRequest("POST", "/api/pastes", nil) r.RemoteAddr = "10.0.1.47:9999" r.Header.Set("CF-Connecting-IP", "198.51.100.9") if got := clientIP(r); got != "10.0.1.47" { - t.Fatalf("unconfigured: clientIP = %q, want peer 10.0.1.47", got) - } - SetTrustedIPHeader("CF-Connecting-IP") - if got := clientIP(r); got != "198.51.100.9" { - t.Fatalf("configured: clientIP = %q, want CF-Connecting-IP value", got) + t.Fatalf("clientIP = %q, want peer 10.0.1.47", got) } } @@ -37,9 +32,7 @@ func TestClientIPTrustedHeaderOnlyWhenConfigured(t *testing.T) { // repro was 8 creates with rotating XFF -> 6x201. func TestRotatingXFFDoesNotResetBucket(t *testing.T) { globalLimiter = newLimiter() - defer SetTrustedIPHeader("") - SetTrustedIPHeader("") - s := defaultSettings(Config{}) // burst/limit defaults; any header values are ignored anyway + s := defaultSettings(Config{}) // burst/limit defaults; header values are ignored anyway var allowed, limited int for i := 0; i < 8; i++ { r := httptest.NewRequest("POST", "/api/pastes", nil) diff --git a/internal/api/server.go b/internal/api/server.go index b5d30d3..787e637 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -28,10 +28,6 @@ type Config struct { DBPath string MaxTextBytes int64 MaxItemBytes int64 - // TrustedIPHeader optionally names a proxy-controlled client-IP header - // (e.g. CF-Connecting-IP behind Cloudflare) to key rate limits on. Empty - // (default) keys on the peer address only. See clientip.go (#280). - TrustedIPHeader string } type apiServer struct { @@ -43,7 +39,6 @@ type apiServer struct { } func NewServer(st *store.Store, cfg Config, ui *web.UI, ss *settingsStore, adminKey string) *apiServer { - SetTrustedIPHeader(cfg.TrustedIPHeader) // #280 return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: adminKey} } -- 2.54.0 From e87bdb221eaebcb69110cc23f21dbd0c4dc8bef8 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 21:45:44 -0500 Subject: [PATCH 2/6] #292: highlight paste name uniformly on row hover in list tables --- internal/web/static/app.css | 3 +++ internal/web/static/history.js | 2 +- internal/web/static/mine.js | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 8c2b963..df77449 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -354,10 +354,13 @@ tr:last-child td { border-bottom: none; } tr.row { cursor: pointer; } tr.row:hover td { background: var(--surface-2); } tr.row:hover td a.slug { color: var(--accent); } +/* #292: highlight the paste NAME uniformly on row hover, titled or not */ +tr.row:hover td .paste-name { color: var(--accent); } td a.slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--fg); text-decoration: none; } td a.slug:hover { color: var(--accent); } /* PASTE column fallback for untitled pastes: plain text, identical to a titled paste. URL/ID chips keep .slug styling. */ td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-family: inherit; font-size: inherit; color: var(--fg); } +td a.paste-name { color: var(--fg); text-decoration: none; font-family: inherit; font-size: inherit; } .badge { font-size: 18.9px; border: 1px solid var(--border); color: var(--muted-fg); border-radius: var(--radius-sm); padding: 1px 8px; } .badge.lock { color: var(--accent); border-color: var(--accent); } .dim { color: var(--muted-fg); white-space: nowrap; } diff --git a/internal/web/static/history.js b/internal/web/static/history.js index ad0ba86..2ad0f55 100644 --- a/internal/web/static/history.js +++ b/internal/web/static/history.js @@ -6,7 +6,7 @@ const t = PaletteTable.init({ rowHtml: it => `` + (it.title - ? `${t.esc(it.title)}${it.is_can ? ' can' : ''}` + ? `${t.esc(it.title)}${it.is_can ? ' can' : ''}` : `${t.esc(it.id)}${it.is_can ? ' can' : ''}`) + `` + `${t.esc(it.type || it.language || 'text')}` + diff --git a/internal/web/static/mine.js b/internal/web/static/mine.js index e074afc..ad1b25e 100644 --- a/internal/web/static/mine.js +++ b/internal/web/static/mine.js @@ -18,7 +18,7 @@ const t = PaletteTable.init({ rowHtml: it => `` + (it.title - ? `${t.esc(it.title)}${it.is_can ? ' can' : ''}` + ? `${t.esc(it.title)}${it.is_can ? ' can' : ''}` : `${t.esc(it.id)}${it.is_can ? ' can' : ''}`) + `` + `${t.esc(it.type || it.language || 'text')}` + -- 2.54.0 From 105ffc9f179203d76ade8de8b29fa3930fc7463b Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 22:12:25 -0500 Subject: [PATCH 3/6] #294: resolve midnight theme variant via lightPreset/darkPreset (midnight is dark-first) --- internal/web/static/settings.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/web/static/settings.js b/internal/web/static/settings.js index 961ff83..17eca0d 100644 --- a/internal/web/static/settings.js +++ b/internal/web/static/settings.js @@ -73,7 +73,10 @@ }); btn.addEventListener('click', function () { var dark = state().dark; - document.documentElement.dataset.preset = dark ? t.id + '-dark' : t.id; + // #294: use the generic variant resolvers - midnight is dark-first + // (dark preset = 'midnight', light = 'midnight-light'), so a raw + // t.id + '-dark' build landed on nonexistent/forced-dark presets. + document.documentElement.dataset.preset = dark ? darkPreset(t.id) : lightPreset(t.id); try { localStorage.setItem('palette-theme', t.id); } catch (e) {} Object.keys(cards).forEach(function (k) { cards[k].setAttribute('aria-pressed', 'false'); }); btn.setAttribute('aria-pressed', 'true'); -- 2.54.0 From 6e85a7686d4ad1f12818fdecc105cf32a935e35d Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 17 Sep 2026 22:54:19 -0500 Subject: [PATCH 4/6] #296: move URL column to position 2 (right after Paste) in history and mine tables --- internal/web/static/history.js | 2 +- internal/web/static/mine.js | 2 +- internal/web/templates/history.html | 4 ++-- internal/web/templates/mine.html | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/web/static/history.js b/internal/web/static/history.js index 2ad0f55..29fcf52 100644 --- a/internal/web/static/history.js +++ b/internal/web/static/history.js @@ -9,9 +9,9 @@ const t = PaletteTable.init({ ? `${t.esc(it.title)}${it.is_can ? ' can' : ''}` : `${t.esc(it.id)}${it.is_can ? ' can' : ''}`) + `` + + (it.custom_slug ? `/${t.esc(it.custom_slug)}` : `none`) + `${t.esc(it.type || it.language || 'text')}` + `${t.fmtSize(it.size)}${it.view_count}${t.ago(it.created_at)}` + - (it.custom_slug ? `/${t.esc(it.custom_slug)}` : `none`) + `${t.esc(it.id)}`, emptyFiltered: 'No pastes match your search.', emptyAll: 'No pastes yet. Create the first one.', diff --git a/internal/web/static/mine.js b/internal/web/static/mine.js index ad1b25e..0318e0f 100644 --- a/internal/web/static/mine.js +++ b/internal/web/static/mine.js @@ -21,9 +21,9 @@ const t = PaletteTable.init({ ? `${t.esc(it.title)}${it.is_can ? ' can' : ''}` : `${t.esc(it.id)}${it.is_can ? ' can' : ''}`) + `` + + (it.custom_slug ? `/${t.esc(it.custom_slug)}` : `none`) + `${t.esc(it.type || it.language || 'text')}` + `${t.fmtSize(it.size)}${t.ago(it.created_at)}` + - (it.custom_slug ? `/${t.esc(it.custom_slug)}` : `none`) + `${t.esc(it.id)}` + ``, emptyFiltered: 'No pastes from this browser match your search.', diff --git a/internal/web/templates/history.html b/internal/web/templates/history.html index 2896a62..d997ac1 100644 --- a/internal/web/templates/history.html +++ b/internal/web/templates/history.html @@ -8,14 +8,14 @@
- + + - diff --git a/internal/web/templates/mine.html b/internal/web/templates/mine.html index 5ef7d9e..35f0429 100644 --- a/internal/web/templates/mine.html +++ b/internal/web/templates/mine.html @@ -8,13 +8,13 @@
PasteURL Type Size Views CreatedURL ID
- + + - -- 2.54.0 From 73c06b864a41e8e899d7bb5421ddea7f2db90064 Mon Sep 17 00:00:00 2001 From: fen Date: Fri, 18 Sep 2026 09:01:03 -0500 Subject: [PATCH 5/6] #296 r2: restore URL column to position 6 and widen to 340px Reverts PR #297's column reorder (owner override): URL returns to position 6 in both list tables (Paste, Type, Size, Views, Created, URL, ID; /mine without Views), data-sort attrs and td cells move back together. Keeps #297's non-position improvements: /mine URL uses the shared .col-url class instead of reusing col-e, and mine's ID column returns to .col-f (100px). Widens .col-url 150px -> 340px so a ~20-char slug displays fully at 1400x900; genuinely long custom slugs keep text ellipsis. No mobile media query needed: <=640px already scrolls the table horizontally (min-width 720px), verified at 375x812. Verified via CDP probe at 1400x900 and 375x812 on /history and /mine: column order, colgroup/header alignment, sort arrow right of label, no other column clipped, no CSP changes (no inline styles added). --- internal/web/static/app.css | 2 +- internal/web/static/history.js | 2 +- internal/web/static/mine.js | 2 +- internal/web/templates/history.html | 4 ++-- internal/web/templates/mine.html | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index df77449..1848f1e 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -896,7 +896,7 @@ button[type="submit"]:focus-visible, .col-f { width: 100px; } .col-g { width: 190px; } /* #255: history's URL column had its own narrow width (col-f doubles as /mine's ID column); give it a dedicated class. */ -.col-url { width: 150px; } +.col-url { width: 340px; } /* #210: /mine rows render a delete button cell that had no declared column, so under table-layout:fixed it overlapped the ID column. */ .col-del { width: 64px; } diff --git a/internal/web/static/history.js b/internal/web/static/history.js index 29fcf52..2ad0f55 100644 --- a/internal/web/static/history.js +++ b/internal/web/static/history.js @@ -9,9 +9,9 @@ const t = PaletteTable.init({ ? `${t.esc(it.title)}${it.is_can ? ' can' : ''}` : `${t.esc(it.id)}${it.is_can ? ' can' : ''}`) + `` + - (it.custom_slug ? `` : ``) + `` + `` + + (it.custom_slug ? `` : ``) + ``, emptyFiltered: 'No pastes match your search.', emptyAll: 'No pastes yet. Create the first one.', diff --git a/internal/web/static/mine.js b/internal/web/static/mine.js index 0318e0f..ad1b25e 100644 --- a/internal/web/static/mine.js +++ b/internal/web/static/mine.js @@ -21,9 +21,9 @@ const t = PaletteTable.init({ ? `${t.esc(it.title)}${it.is_can ? ' can' : ''}` : `${t.esc(it.id)}${it.is_can ? ' can' : ''}`) + `` + - (it.custom_slug ? `` : ``) + `` + `` + + (it.custom_slug ? `` : ``) + `` + ``, emptyFiltered: 'No pastes from this browser match your search.', diff --git a/internal/web/templates/history.html b/internal/web/templates/history.html index d997ac1..2896a62 100644 --- a/internal/web/templates/history.html +++ b/internal/web/templates/history.html @@ -8,14 +8,14 @@
PasteURL Type Size CreatedURL ID
/${t.esc(it.custom_slug)}none${t.esc(it.type || it.language || 'text')}${t.fmtSize(it.size)}${it.view_count}${t.ago(it.created_at)}/${t.esc(it.custom_slug)}none${t.esc(it.id)}
/${t.esc(it.custom_slug)}none${t.esc(it.type || it.language || 'text')}${t.fmtSize(it.size)}${t.ago(it.created_at)}/${t.esc(it.custom_slug)}none${t.esc(it.id)}
- + - + diff --git a/internal/web/templates/mine.html b/internal/web/templates/mine.html index 35f0429..e20981d 100644 --- a/internal/web/templates/mine.html +++ b/internal/web/templates/mine.html @@ -8,13 +8,13 @@
PasteURL Type Size Views CreatedURL ID
- + - + -- 2.54.0 From ce14c6b713065249cb36d2b11ddb517debcf0c7e Mon Sep 17 00:00:00 2001 From: fen Date: Fri, 18 Sep 2026 11:15:48 -0500 Subject: [PATCH 6/6] #296 r3: shrink URL column, render URL values as plain text Owner override postdating PR #300 QA pass: - .col-url 340 -> 230px; reclaim slack from history-only columns (Views 96->90, Created 190->150, ID 190->120) so the table fits the .float pane with no horizontal scroll at 1400x900 and the ID column is fully visible. - URL anchors drop the shared .slug pill (background/padding/radius/mono font) and render plain, styled via td.dim exactly like the none case; links stay anchors with accent hover. Ellipsis kept for genuinely long slugs. Verified by CDP probe on a local build at 1400x900 and 375x812 on /public and /mine (/saved): column order, table fits pane (scrollWidth == clientWidth), 20-char slug fully visible, min Created-to-URL content gap 77px, sort arrows intact 6px right of labels, no CSP/console violations. go build/test pass. --- internal/web/static/app.css | 10 ++++++---- internal/web/static/history.js | 2 +- internal/web/static/mine.js | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 1848f1e..b6f9634 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -357,6 +357,8 @@ tr.row:hover td a.slug { color: var(--accent); } /* #292: highlight the paste NAME uniformly on row hover, titled or not */ tr.row:hover td .paste-name { color: var(--accent); } td a.slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--fg); text-decoration: none; } +td a.url-link { font-family: inherit; font-size: inherit; color: inherit; text-decoration: none; } +td a.url-link:hover { color: var(--accent); } td a.slug:hover { color: var(--accent); } /* PASTE column fallback for untitled pastes: plain text, identical to a titled paste. URL/ID chips keep .slug styling. */ td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-family: inherit; font-size: inherit; color: var(--fg); } @@ -531,7 +533,7 @@ td a.paste-name { color: var(--fg); text-decoration: none; font-family: inherit; /* paste name under slug pill in Paste column (#43) */ .paste-sub { font-size: 19.8px; color: var(--muted-fg); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .paste-sub.dim { color: var(--muted); } -td a.slug.url-link { max-width: 100%; display: inline-block; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; box-sizing: border-box; } +td a.url-link { max-width: 100%; display: inline-block; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; box-sizing: border-box; } td .id-link { color: var(--muted-fg); text-decoration: none; font-family: var(--font-mono); font-size: 19.8px; } td .id-link:hover { color: var(--accent); } @@ -892,11 +894,11 @@ button[type="submit"]:focus-visible, .hidden { display: none; } .can-page { max-width: 900px; width: 100%; } .col-a { width: 260px; } .col-b { width: 140px; } .col-c { width: 120px; } -.col-d { width: 96px; } .col-d2 { width: 150px; } .col-e { width: 190px; } -.col-f { width: 100px; } .col-g { width: 190px; } +.col-d { width: 90px; } .col-d2 { width: 150px; } .col-e { width: 150px; } +.col-f { width: 100px; } .col-g { width: 120px; } /* #255: history's URL column had its own narrow width (col-f doubles as /mine's ID column); give it a dedicated class. */ -.col-url { width: 340px; } +.col-url { width: 230px; } /* #210: /mine rows render a delete button cell that had no declared column, so under table-layout:fixed it overlapped the ID column. */ .col-del { width: 64px; } diff --git a/internal/web/static/history.js b/internal/web/static/history.js index 2ad0f55..32392c4 100644 --- a/internal/web/static/history.js +++ b/internal/web/static/history.js @@ -11,7 +11,7 @@ const t = PaletteTable.init({ `` + `` + `` + - (it.custom_slug ? `` : ``) + + (it.custom_slug ? `` : ``) + ``, emptyFiltered: 'No pastes match your search.', emptyAll: 'No pastes yet. Create the first one.', diff --git a/internal/web/static/mine.js b/internal/web/static/mine.js index ad1b25e..6894205 100644 --- a/internal/web/static/mine.js +++ b/internal/web/static/mine.js @@ -23,7 +23,7 @@ const t = PaletteTable.init({ `` + `` + `` + - (it.custom_slug ? `` : ``) + + (it.custom_slug ? `` : ``) + `` + ``, emptyFiltered: 'No pastes from this browser match your search.', -- 2.54.0
PasteURL Type Size CreatedURL ID
${t.esc(it.type || it.language || 'text')}${t.fmtSize(it.size)}${it.view_count}${t.ago(it.created_at)}/${t.esc(it.custom_slug)}none/${t.esc(it.custom_slug)}none${t.esc(it.id)}
${t.esc(it.type || it.language || 'text')}${t.fmtSize(it.size)}${t.ago(it.created_at)}/${t.esc(it.custom_slug)}none/${t.esc(it.custom_slug)}none${t.esc(it.id)}