18 Commits
Author SHA1 Message Date
fen cb1b16ae41 Merge pull request 'Release v0.5.0: dev -> main' (#288) from dev into main
CI / test (push) Successful in 26s
CI / docker (push) Successful in 46s
2026-09-18 01:49:23 +00:00
fen 880f3cd958 Merge pull request 'Sync main into dev (owner README edit #279)' (#290) from dev-main-sync into dev
CI / test (push) Successful in 26s
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
CI / docker (push) Successful in 46s
2026-09-18 01:48:43 +00:00
fen 1149989d07 Merge pull request 'Release v0.5.0: dev -> main' (#288) from dev into main
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
# Conflicts:
#	README.md
2026-09-17 20:46:42 -05:00
fen ed435c5e13 Merge pull request 'Fix #282: jumpnav visible on mobile initial load' (#285) from fix-282 into dev
CI / test (push) Successful in 28s
CI / docker (push) Successful in 46s
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
2026-09-18 01:35:15 +00:00
fen c190dd9ea6 Merge pull request '#281: /raw/{id} returns empty body for non-image attachment pastes (Fix attempt 1)' (#286) from fix-281 into dev
CI / test (push) Successful in 32s
CI / docker (push) Successful in 43s
2026-09-18 01:33:45 +00:00
fen d4da322031 Merge pull request 'Release v0.5.0: README refresh' (#278) from release-readme-v0.5.0 into dev
CI / test (push) Successful in 30s
CI / docker (push) Successful in 53s
2026-09-18 01:33:27 +00:00
fen eca8533d70 Merge pull request 'Fix #280: rate limiter keys buckets on peer address, not client-controlled X-Forwarded-For' (#284) from fix-280 into dev
CI / test (push) Successful in 28s
CI / docker (push) Successful in 47s
2026-09-18 01:33:04 +00:00
fen 5770b2f88e Merge pull request 'Sync main into dev before v0.5.0 release' (#277) from dev-sync into dev
CI / test (push) Successful in 25s
CI / docker (push) Successful in 52s
2026-09-18 01:32:55 +00:00
fen 8901a3c82c #281: /raw streams attachment blob for all attachment mimes
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
handleRaw only streamed the blob behind an isImageMime gate (#221), so
non-image attachment pastes fell through to empty row.Content and /raw
served 0 bytes. Serve the blob for every attachment mime, passing the
sniffed mime through serveContentType so active-content types (html,
svg, xml) still serve as text/plain per the #34 rule. Regression tests
cover text and html attachments (size, Content-Type, byte equality).
2026-09-17 20:31:57 -05:00
fen 3460d54fce Fix #282: re-evaluate jumpnav visibility after layout settles on load
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
The initial refresh() ran before the mobile layout settled (media queries,
fonts, async highlighting) and under-measured the content, leaving #jumpnav
hidden on long pastes at 375x812 until a resize event. Re-check after a
double rAF, on window load, after 300ms, and via a ResizeObserver on
document.body for late content growth. Editor textarea scroller unchanged.
2026-09-17 20:30:04 -05:00
fen f63efc6d88 Fix rate limiter bypass via client-controlled X-Forwarded-For (#280)
CI / test (pull_request) Successful in 25s
CI / docker (pull_request) Skipped
clientIP() keyed rate-limit buckets on the rightmost X-Forwarded-For
entry, assuming traefik appends the real client IP. The deployed ingress
does not rewrite XFF, so rotating the header gave a fresh bucket per
request (pentest H1: 8 creates with rotating XFF -> 6x201).

Now the bucket keys on the actual peer address (RemoteAddr) by default;
every client-supplied IP header is ignored. Deployments whose ingress
overwrites a client-IP header can opt in via PALETTE_TRUSTED_IP_HEADER
(e.g. CF-Connecting-IP behind Cloudflare) to restore per-client limits.

Adds tests: rotating XFF no longer resets the bucket; the trusted header
is honored only when explicitly configured.
2026-09-17 20:29:40 -05:00
poslop bc52f0a608 Merge pull request 'Update README.md' (#279) from poslop-patch-1 into main
CI / test (push) Successful in 24s
CI / docker (push) Skipped
Reviewed-on: #279
2026-09-18 01:21:36 +00:00
poslop 84d19556fc Update README.md
CI / test (pull_request) Successful in 27s
CI / docker (pull_request) Skipped
2026-09-18 01:21:27 +00:00
fen f3fe2335d4 Release v0.5.0: README refresh
CI / test (pull_request) Successful in 27s
CI / docker (pull_request) Skipped
- Add code-viewer polish line to the feature list (pinned gutter sized to
  the widest number, line wrap, jump buttons, theme-aware scrollbars)
- Extend mobile preview links (editor, dark and light paste views)
- Clarify PALETTE_MAX_ITEM covers can items and file attachments
- Document PALETTE_DEFAULT_DARK in docker-compose.yml so the compose file
  really covers every env var
2026-09-17 20:10:25 -05:00
fen fe5960f803 Merge remote-tracking branch 'origin/main' into dev-sync
CI / test (pull_request) Successful in 24s
CI / docker (pull_request) Skipped
2026-09-17 19:55:30 -05:00
poslop e81f825ab8 Merge pull request 'Update README.md' (#252) from poslop-adjust-images into main
CI / test (push) Successful in 19s
CI / docker (push) Skipped
Reviewed-on: #252
2026-09-17 19:07:11 +00:00
poslop 3d50a264ed Update README.md
CI / test (pull_request) Successful in 20s
CI / docker (pull_request) Skipped
2026-09-17 19:02:36 +00:00
fen 522e016a29 Merge pull request 'Release v0.4.0: dev -> main' (#251) from dev into main
CI / test (push) Successful in 19s
CI / docker (push) Successful in 36s
2026-09-17 15:35:20 +00:00
9 changed files with 191 additions and 117 deletions
+6 -5
View File
@@ -21,16 +21,16 @@ a web UI for sharing text and small files.
- Cookie based saved pastes and settings - Cookie based saved pastes and settings
- Five base themes (midnight, smooth, pastel-lavender, pastel-peach, pastel-cloud), each with a dark and light variant - Five base themes (midnight, smooth, pastel-lavender, pastel-peach, pastel-cloud), each with a dark and light variant
- Dark mode toggle in the topbar and settings, with a configurable default - Dark mode toggle in the topbar and settings, with a configurable default
- Polished code viewer: line-number gutter sized to the widest number and pinned during horizontal scroll, optional line wrap, jump-to-top/bottom buttons, and theme-aware scrollbars
## Screenshots ## Screenshots
| | | | | |
|---|---| |---|---|
| ![Editor in midnight (dark)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-editor-new.png) | ![Paste view in pastel-peach (light)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-paste-pastel-peach-light.png) | | ![Editor in midnight (dark)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-editor-new.png) | ![Paste view in pastel-peach (light)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-paste-pastel-peach-light.png) |
| ![Paste view in midnight (dark)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-paste-midnight-dark.png) | ![Settings and theme picker](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-settings-themes.png) | | ![Public pastes list](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-public.png) | ![Settings and theme picker](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-settings-themes.png) |
| ![Public pastes list](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-public.png) | ![Editor at mobile width](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-editor-new.png) |
Mobile previews (375x812): [paste view](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-paste-midnight-dark.png), [public list](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-public.png), [settings](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-settings.png).
## Get Started ## Get Started
@@ -66,10 +66,11 @@ go build -o palette ./cmd/palette
| `PALETTE_ADDR` | `:8080` | Listen address | | `PALETTE_ADDR` | `:8080` | Listen address |
| `PALETTE_DB` | `palette.db` | SQLite database path | | `PALETTE_DB` | `palette.db` | SQLite database path |
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) | | `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
| `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) | | `PALETTE_MAX_ITEM` | `26214400` | Max can item / file attachment size in bytes (25 MB) |
| `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
@@ -101,4 +102,4 @@ data loss, or legal issues arising from use of the software. You use it
at your own risk. at your own risk.
If you run a modified version of Palette as a network service, the AGPL If you run a modified version of Palette as a network service, the AGPL
requires you to offer your modified source code to its users. requires you to offer your modified source code to its users.
+7 -1
View File
@@ -32,10 +32,16 @@ services:
# Default: 5242880 (5 MiB). # Default: 5242880 (5 MiB).
# PALETTE_MAX_TEXT: "5242880" # PALETTE_MAX_TEXT: "5242880"
# Max size in bytes of a single can item (file/text inside a can). # Max size in bytes of a single can item (file/text inside a can) or a
# paste file attachment.
# Default: 26214400 (25 MiB). # Default: 26214400 (25 MiB).
# PALETTE_MAX_ITEM: "26214400" # PALETTE_MAX_ITEM: "26214400"
# Default dark mode for new visitors. Unset = dark on; set to "false",
# "0" or "off" to default to light mode. Visitors who toggle dark mode
# keep their choice in their browser.
# PALETTE_DEFAULT_DARK: "false"
# HMAC secret for password-unlock cookies. Default: random per start, # HMAC secret for password-unlock cookies. Default: random per start,
# which logs out every unlocked browser session on restart. Set a fixed # which logs out every unlocked browser session on restart. Set a fixed
# secret (any random string) to keep unlock sessions across restarts, # secret (any random string) to keep unlock sessions across restarts,
-11
View File
@@ -91,17 +91,6 @@ func (l *limitReader) Read(p []byte) (int, error) {
return n, err return n, err
} }
// isImageMime reports whether the sniffed mime is a raster image the viewer
// can render inline (#221). SVG is excluded: it is forced to text/plain on
// serving by the active-content rule and must never render as an image.
func isImageMime(mime string) bool {
switch mime {
case "image/png", "image/jpeg", "image/gif", "image/webp":
return true
}
return false
}
// handleCreatePasteMultipart implements POST /api/pastes with // handleCreatePasteMultipart implements POST /api/pastes with
// multipart/form-data (#38). Fields mirror the JSON create path; a 'file' // multipart/form-data (#38). Fields mirror the JSON create path; a 'file'
// part makes the paste a file paste (1 file = 1 paste: if text content is // part makes the paste a file paste (1 file = 1 paste: if text content is
+61
View File
@@ -306,3 +306,64 @@ func TestMultipartPasswordFieldAccepted(t *testing.T) {
t.Fatalf("paste should require password, got %d", rec2.Code) t.Fatalf("paste should require password, got %d", rec2.Code)
} }
} }
// #281: /raw/{id} must stream the attachment blob for ALL attachment mimes,
// not just raster images (the old isImageMime gate left non-image
// attachments serving an empty body from row.Content).
func TestRawStreamsNonImageAttachment(t *testing.T) {
s := testServer(t)
h := s.routes()
body := []byte("hello, this is a plain text attachment body")
rec, resp := multipartCreate(t, h, "notes.txt", body, nil)
if rec.Code != 201 {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
if resp["attachment"] == nil {
t.Fatalf("no attachment in response: %v", resp)
}
id, _ := resp["id"].(string)
req := httptest.NewRequest("GET", "/raw/"+id, nil)
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, req)
if rec2.Code != 200 {
t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String())
}
if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Fatalf("Content-Type = %q", got)
}
if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("nosniff = %q", got)
}
if !bytes.Equal(rec2.Body.Bytes(), body) {
t.Fatalf("raw bytes differ: got %d bytes want %d", rec2.Body.Len(), len(body))
}
}
// #281: active-content attachment types still get forced to text/plain on
// /raw, same rule as the /f/ serving path (#34).
func TestRawHtmlAttachmentServesAsPlainText(t *testing.T) {
s := testServer(t)
h := s.routes()
html := []byte("<html><body><script>alert(1)</script></body></html>")
rec, resp := multipartCreate(t, h, "page.html", html, nil)
if rec.Code != 201 {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
id, _ := resp["id"].(string)
req := httptest.NewRequest("GET", "/raw/"+id, nil)
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, req)
if rec2.Code != 200 {
t.Fatalf("raw: %d %s", rec2.Code, rec2.Body.String())
}
if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Fatalf("Content-Type = %q", got)
}
if !bytes.Equal(rec2.Body.Bytes(), html) {
t.Fatal("raw bytes differ from upload")
}
}
+57
View File
@@ -0,0 +1,57 @@
// clientIP extracts the client IP for rate-limit keying.
//
// Trust boundary (issue #280): the bucket key MUST NOT come from any header a
// client can influence. The previous rightmost-X-Forwarded-For scheme (#85)
// assumed Traefik appends the real client IP, but the deployed ingress does
// not rewrite XFF, so a client rotating its own XFF value got a fresh bucket
// 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
// 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.
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
}
return host
}
-30
View File
@@ -3,7 +3,6 @@ package api
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
) )
@@ -48,35 +47,6 @@ func (l *limiter) allow(key string, rate, burst float64) bool {
return true return true
} }
// clientIP extracts the client IP for rate-limit keying (#85).
//
// Trust boundary: palette runs behind exactly ONE trusted reverse proxy
// (Traefik in the k3s pod network). Traefik APPENDS the real client IP to
// X-Forwarded-For, so the RIGHTMOST entry is the last value the trusted
// proxy observed and is unspoofable by the client (a client-supplied fake
// entry only lands on the LEFT and is ignored). This matches chi's
// middleware.RealIP semantics for a single trusted proxy hop.
//
// Direct connections (no XFF header) fall back to RemoteAddr. Directly
// reachable deployments must NOT expose the app to untrusted networks
// without a proxy in front, or attackers could forge the rightmost entry.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.LastIndex(xff, ","); i >= 0 {
return strings.TrimSpace(xff[i+1:])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-Ip"); xr != "" {
return strings.TrimSpace(xr)
}
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i > 0 {
host = host[:i]
}
return host
}
var globalLimiter = newLimiter() var globalLimiter = newLimiter()
// globalSettingsFn is set at startup; tests can point it at fixed settings. // globalSettingsFn is set at startup; tests can point it at fixed settings.
+38 -66
View File
@@ -1,85 +1,57 @@
package api package api
// Issue #85: the rate limit key must use the rightmost X-Forwarded-For entry
// (appended by the trusted Traefik proxy), never the raw/leftmost header
// value a client can forge. A spoofed FIRST XFF entry must not bypass the
// limit or rotate buckets.
import ( import (
"bytes" "fmt"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
) )
func TestClientIPTakesRightmostXFF(t *testing.T) { func TestClientIPUsesRemoteAddrNotXFF(t *testing.T) {
r := httptest.NewRequest("POST", "/", nil) SetTrustedIPHeader("")
r.RemoteAddr = "10.42.0.7:51000" // trusted Traefik pod 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") r.Header.Set("X-Forwarded-For", "1.2.3.4, 1.2.3.5, 203.0.113.9")
if got := clientIP(r); got != "203.0.113.9" {
t.Fatalf("clientIP = %q, want rightmost 203.0.113.9", got)
}
}
func TestClientIPXRealIPFallback(t *testing.T) {
r := httptest.NewRequest("POST", "/", nil)
r.RemoteAddr = "10.42.0.7:51000"
r.Header.Set("X-Real-Ip", "203.0.113.10") r.Header.Set("X-Real-Ip", "203.0.113.10")
if got := clientIP(r); got != "203.0.113.10" { if got := clientIP(r); got != "203.0.113.7" {
t.Fatalf("clientIP = %q, want 203.0.113.10", got) t.Fatalf("clientIP = %q, want peer 203.0.113.7", got)
} }
} }
func TestClientIPDirectFallback(t *testing.T) { func TestClientIPTrustedHeaderOnlyWhenConfigured(t *testing.T) {
r := httptest.NewRequest("POST", "/", nil) SetTrustedIPHeader("")
r.RemoteAddr = "198.51.100.5:51000" defer SetTrustedIPHeader("")
if got := clientIP(r); got != "198.51.100.5" { r := httptest.NewRequest("POST", "/api/pastes", nil)
t.Fatalf("clientIP = %q, want 198.51.100.5", got) 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)
} }
} }
// TestRateLimitSpoofedFirstXFFDoesNotBypass: an attacker rotating a fake // Issue #280: rotating X-Forwarded-For must NOT reset the bucket. Pentest
// leftmost XFF entry stays limited on their real (rightmost) IP. // repro was 8 creates with rotating XFF -> 6x201.
func TestRateLimitSpoofedFirstXFFDoesNotBypass(t *testing.T) { func TestRotatingXFFDoesNotResetBucket(t *testing.T) {
srv := newTestServer(t) globalLimiter = newLimiter()
h := srv.routes() defer SetTrustedIPHeader("")
for i := 0; i < 5; i++ { SetTrustedIPHeader("")
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`))) s := defaultSettings(Config{}) // burst/limit defaults; any header values are ignored anyway
req.RemoteAddr = "10.42.0.7:51000" var allowed, limited int
// each request spoofs a DIFFERENT leftmost entry for i := 0; i < 8; i++ {
req.Header.Set("X-Forwarded-For", spoofN(i)+", 203.0.113.9") r := httptest.NewRequest("POST", "/api/pastes", nil)
rr := httptest.NewRecorder() r.RemoteAddr = "198.51.100.1:5000"
h.ServeHTTP(rr, req) r.Header.Set("X-Forwarded-For", fmt.Sprintf("9.9.9.%d", i))
if rr.Code != 201 { if rateLimitCreate(r, s) {
t.Fatalf("req %d: want 201, got %d", i, rr.Code) allowed++
} else {
limited++
} }
} }
// 6th request, still the same real IP, new spoofed prefix: must 429 if float64(allowed) != s.RateLimitBurst || limited != 8-int(s.RateLimitBurst) {
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`))) t.Fatalf("rotating XFF: allowed=%d limited=%d, want allowed=%v (burst), limited=%d", allowed, limited, s.RateLimitBurst, 8-int(s.RateLimitBurst))
req.RemoteAddr = "10.42.0.7:51000"
req.Header.Set("X-Forwarded-For", "9.9.9.9, 203.0.113.9")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 429 {
t.Fatalf("spoofed 6th req: want 429, got %d", rr.Code)
}
}
func spoofN(i int) string {
return "1.2.3." + string(rune('0'+i))
}
// Distinct real IPs must still get distinct buckets (no over-limiting).
func TestRateLimitDistinctRightmostIPsIndependent(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
for _, ip := range []string{"203.0.113.20", "203.0.113.21"} {
req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
req.RemoteAddr = "10.42.0.7:51000"
req.Header.Set("X-Forwarded-For", "6.6.6.6, "+ip)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 201 {
t.Fatalf("ip %s: want 201, got %d", ip, rr.Code)
}
} }
} }
+12 -4
View File
@@ -28,6 +28,10 @@ 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 {
@@ -39,6 +43,7 @@ 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}
} }
@@ -496,15 +501,18 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", 404) http.Error(w, "not found", 404)
return return
} }
// #221: raw view of an image paste serves the image bytes themselves as // #221: raw view of a paste backed by an attachment serves the stored
// an image, not the (empty) text content. // blob bytes with the sniffed mime, not the (empty) text content — for
if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil && isImageMime(att.Mime) { // ALL attachment mimes (#281); /raw/{id} is the raw fetch for the file
// too. serveContentType still forces active-content types (html, svg,
// xml) to text/plain per the #34 rule below.
if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil {
blobs := a.store.Blobs() blobs := a.store.Blobs()
if blobs != nil { if blobs != nil {
if blob, err := blobs.Get(row.ID + "/" + att.SHA256); err == nil { if blob, err := blobs.Get(row.ID + "/" + att.SHA256); err == nil {
defer blob.Close() defer blob.Close()
a.store.IncrementViews(row.ID, "", 0) // raw views always count (#49/#95) a.store.IncrementViews(row.ID, "", 0) // raw views always count (#49/#95)
w.Header().Set("Content-Type", att.Mime) w.Header().Set("Content-Type", serveContentType(att.Mime))
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", fmt.Sprintf("%d", att.Size)) w.Header().Set("Content-Length", fmt.Sprintf("%d", att.Size))
http.ServeContent(w, r, "", time.Unix(att.CreatedAt, 0), blob) http.ServeContent(w, r, "", time.Unix(att.CreatedAt, 0), blob)
+10
View File
@@ -38,4 +38,14 @@
window.addEventListener('resize', refresh); window.addEventListener('resize', refresh);
if (scroller !== window && scroller) scroller.addEventListener('input', refresh); if (scroller !== window && scroller) scroller.addEventListener('input', refresh);
refresh(); refresh();
/* #282: the first evaluation can run before the layout settles (media
queries, web fonts, async highlighting) and under-measure the content,
leaving the nav hidden on long pages. Re-check once a real layout exists
and after load; the ResizeObserver also catches late content growth. */
requestAnimationFrame(function () { requestAnimationFrame(refresh); });
window.addEventListener('load', refresh);
window.setTimeout(refresh, 300);
if (window.ResizeObserver && scroller === window && document.body) {
new ResizeObserver(refresh).observe(document.body);
}
})(); })();