6 Commits
Author SHA1 Message Date
fen 2046e45601 Merge pull request '#294: switching to midnight theme forces dark mode' (#295) from fix-294 into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 34s
2026-09-18 03:14:20 +00:00
fen 105ffc9f17 #294: resolve midnight theme variant via lightPreset/darkPreset (midnight is dark-first)
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
2026-09-17 22:12:25 -05:00
fen 4fa60ea7b9 Merge pull request '#292: highlight paste name uniformly on row hover in list tables' (#293) from fix-292 into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 35s
2026-09-18 02:58:13 +00:00
fen e87bdb221e #292: highlight paste name uniformly on row hover in list tables
CI / test (pull_request) Successful in 26s
CI / docker (pull_request) Skipped
2026-09-17 21:45:44 -05:00
fen aa72b4b38b Merge pull request '#280: remove PALETTE_TRUSTED_IP_HEADER, rate limits key on peer address only' (#291) from fix-280-r2 into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 42s
2026-09-18 01:53:23 +00:00
fen aac1725c25 #280: remove PALETTE_TRUSTED_IP_HEADER, key rate limits on peer address only
CI / test (pull_request) Successful in 31s
CI / docker (pull_request) Skipped
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.
2026-09-17 20:48:52 -05:00
8 changed files with 18 additions and 55 deletions
-1
View File
@@ -70,7 +70,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 `<db-dir>/admin-key` (0600) | | `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<db-dir>/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_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_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 An `/admin` page exists for runtime settings, protected by a key set at
install (`PALETTE_ADMIN_KEY` env var) and resettable locally. See install (`PALETTE_ADMIN_KEY` env var) and resettable locally. See
+3 -33
View File
@@ -7,48 +7,18 @@
// per request and the limit was unenforceable (pentest H1: 6x201 across 8 // per request and the limit was unenforceable (pentest H1: 6x201 across 8
// rotating-XFF creates). // 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 // reverse proxy this is the proxy's address, so all clients share one bucket
// per endpoint — coarse, but safe. // per endpoint — coarse, but safe. Client-supplied IP headers
// // (X-Forwarded-For, X-Real-Ip) are never trusted.
// 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.
package api package api
import ( import (
"net" "net"
"net/http" "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 { func clientIP(r *http.Request) string {
if name := getTrustedIPHeader(); name != "" {
if v := r.Header.Get(name); v != "" {
return v
}
}
host := r.RemoteAddr host := r.RemoteAddr
if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
host = h host = h
+6 -13
View File
@@ -7,8 +7,6 @@ import (
) )
func TestClientIPUsesRemoteAddrNotXFF(t *testing.T) { func TestClientIPUsesRemoteAddrNotXFF(t *testing.T) {
SetTrustedIPHeader("")
defer SetTrustedIPHeader("")
r := httptest.NewRequest("POST", "/api/pastes", nil) r := httptest.NewRequest("POST", "/api/pastes", nil)
r.RemoteAddr = "203.0.113.7:4432" 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") 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) { // A proxy-controlled header is not honored even when set: #280 revision
SetTrustedIPHeader("") // removed the PALETTE_TRUSTED_IP_HEADER mechanism per owner decision, so the
defer SetTrustedIPHeader("") // bucket key is the peer address only.
func TestClientIPNeverTrustsHeaders(t *testing.T) {
r := httptest.NewRequest("POST", "/api/pastes", nil) r := httptest.NewRequest("POST", "/api/pastes", nil)
r.RemoteAddr = "10.0.1.47:9999" r.RemoteAddr = "10.0.1.47:9999"
r.Header.Set("CF-Connecting-IP", "198.51.100.9") r.Header.Set("CF-Connecting-IP", "198.51.100.9")
if got := clientIP(r); got != "10.0.1.47" { if got := clientIP(r); got != "10.0.1.47" {
t.Fatalf("unconfigured: clientIP = %q, want peer 10.0.1.47", got) t.Fatalf("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)
} }
} }
@@ -37,9 +32,7 @@ func TestClientIPTrustedHeaderOnlyWhenConfigured(t *testing.T) {
// repro was 8 creates with rotating XFF -> 6x201. // repro was 8 creates with rotating XFF -> 6x201.
func TestRotatingXFFDoesNotResetBucket(t *testing.T) { func TestRotatingXFFDoesNotResetBucket(t *testing.T) {
globalLimiter = newLimiter() globalLimiter = newLimiter()
defer SetTrustedIPHeader("") s := defaultSettings(Config{}) // burst/limit defaults; header values are ignored anyway
SetTrustedIPHeader("")
s := defaultSettings(Config{}) // burst/limit defaults; any header values are ignored anyway
var allowed, limited int var allowed, limited int
for i := 0; i < 8; i++ { for i := 0; i < 8; i++ {
r := httptest.NewRequest("POST", "/api/pastes", nil) r := httptest.NewRequest("POST", "/api/pastes", nil)
-5
View File
@@ -28,10 +28,6 @@ type Config struct {
DBPath string DBPath string
MaxTextBytes int64 MaxTextBytes int64
MaxItemBytes 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 { 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 { 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} return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: adminKey}
} }
+3
View File
@@ -354,10 +354,13 @@ tr:last-child td { border-bottom: none; }
tr.row { cursor: pointer; } tr.row { cursor: pointer; }
tr.row:hover td { background: var(--surface-2); } tr.row:hover td { background: var(--surface-2); }
tr.row:hover td a.slug { color: var(--accent); } 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 { font-family: var(--font-mono); font-size: 21.6px; color: var(--fg); text-decoration: none; }
td a.slug: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. */ /* 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.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 { 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); } .badge.lock { color: var(--accent); border-color: var(--accent); }
.dim { color: var(--muted-fg); white-space: nowrap; } .dim { color: var(--muted-fg); white-space: nowrap; }
+1 -1
View File
@@ -6,7 +6,7 @@ const t = PaletteTable.init({
rowHtml: it => rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` + `<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title (it.title
? `${t.esc(it.title)}${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}` ? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) + : `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</td>` + `</td>` +
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` + `<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` +
+1 -1
View File
@@ -18,7 +18,7 @@ const t = PaletteTable.init({
rowHtml: it => rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` + `<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title (it.title
? `${t.esc(it.title)}${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}` ? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) + : `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
`</td>` + `</td>` +
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` + `<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` +
+4 -1
View File
@@ -73,7 +73,10 @@
}); });
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
var dark = state().dark; 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) {} try { localStorage.setItem('palette-theme', t.id); } catch (e) {}
Object.keys(cards).forEach(function (k) { cards[k].setAttribute('aria-pressed', 'false'); }); Object.keys(cards).forEach(function (k) { cards[k].setAttribute('aria-pressed', 'false'); });
btn.setAttribute('aria-pressed', 'true'); btn.setAttribute('aria-pressed', 'true');