Compare commits
22
Commits
ca0e51192a
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb1b16ae41 | ||
|
|
880f3cd958 | ||
|
|
1149989d07 | ||
|
|
ed435c5e13 | ||
|
|
c190dd9ea6 | ||
|
|
d4da322031 | ||
|
|
eca8533d70 | ||
|
|
5770b2f88e | ||
|
|
8901a3c82c | ||
|
|
3460d54fce | ||
|
|
f63efc6d88 | ||
|
|
bc52f0a608 | ||
|
|
84d19556fc | ||
|
|
f3fe2335d4 | ||
|
|
fe5960f803 | ||
|
|
70b06db192 | ||
|
|
7fdb3ee61c | ||
|
|
ad397b80d0 | ||
|
|
714b4691e9 | ||
|
|
e81f825ab8 | ||
|
|
3d50a264ed | ||
|
|
522e016a29 |
@@ -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
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
|  |  |
|
|  |  |
|
||||||
|  |  |
|
|  |  |
|
||||||
|  |  |
|
|
||||||
|
|
||||||
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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -319,8 +319,10 @@ html[data-wrap] .float { overflow-x: hidden; }
|
|||||||
.codebody { padding: 0 18px; white-space: pre; overflow-x: auto; }
|
.codebody { padding: 0 18px; white-space: pre; overflow-x: auto; }
|
||||||
/* #167: each logical line is its own block so offsetTop identifies its first visual row */
|
/* #167: each logical line is its own block so offsetTop identifies its first visual row */
|
||||||
.codeline { display: block; }
|
.codeline { display: block; }
|
||||||
/* #167 rev: gutter number spans must stack one per visual row (wrap on) */
|
/* #167: gutter number spans must stack one per visual row (wrap on).
|
||||||
.code .gutter .gutline { display: block; }
|
#274: the /new editor gutter uses the same .gutline blocks when its own
|
||||||
|
wrap toggle is on, so scope the rule to any gutter, not just .code. */
|
||||||
|
.code .gutter .gutline, .editor-wrap .gutter .gutline { display: block; }
|
||||||
/* #194: codeline blocks are adjacent (no '\n' text between them), so an
|
/* #194: codeline blocks are adjacent (no '\n' text between them), so an
|
||||||
empty block (blank source line) needs its own line box to stay one row */
|
empty block (blank source line) needs its own line box to stay one row */
|
||||||
.codeline:empty::before { content: "\200B"; }
|
.codeline:empty::before { content: "\200B"; }
|
||||||
@@ -921,3 +923,19 @@ button[type="submit"]:focus-visible,
|
|||||||
}
|
}
|
||||||
.jumpnav.hidden { display: none; }
|
.jumpnav.hidden { display: none; }
|
||||||
.jump-btn { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); }
|
.jump-btn { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); }
|
||||||
|
|
||||||
|
/* #273: theme-aware scrollbars. Standard properties first (Firefox, and
|
||||||
|
Chromium >= 121 honors scrollbar-color), then ::-webkit rules for finer
|
||||||
|
Chromium styling. Colors come from CSS vars so they track the preset. */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border) transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--muted-fg); }
|
||||||
|
::-webkit-scrollbar-corner { background: transparent; }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -2,13 +2,71 @@
|
|||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
const content = $('content'), gutter = $('gutter');
|
const content = $('content'), gutter = $('gutter');
|
||||||
|
|
||||||
|
// #274: with wrap on, a logical line occupies several VISUAL rows in the
|
||||||
|
// textarea, so one number per logical line drifts off its text (same bug the
|
||||||
|
// paste view fixed in #167). A textarea can't be split into spans, so the
|
||||||
|
// wrapped row count per logical line is measured with a hidden mirror div
|
||||||
|
// that shares the editor's font, line metrics and wrapping rules, and the
|
||||||
|
// gutter renders one .gutline block per visual row with the number on the
|
||||||
|
// FIRST row of its logical line (fillers elsewhere).
|
||||||
|
let mirror = null;
|
||||||
|
function measureRows(lines) {
|
||||||
|
if (!mirror) {
|
||||||
|
mirror = document.createElement('div');
|
||||||
|
mirror.style.position = 'absolute';
|
||||||
|
mirror.style.visibility = 'hidden';
|
||||||
|
mirror.style.top = '0';
|
||||||
|
mirror.style.left = '-9999px';
|
||||||
|
document.body.appendChild(mirror);
|
||||||
|
}
|
||||||
|
const cs = getComputedStyle(content);
|
||||||
|
mirror.style.font = cs.font;
|
||||||
|
mirror.style.lineHeight = cs.lineHeight;
|
||||||
|
mirror.style.whiteSpace = 'pre-wrap';
|
||||||
|
mirror.style.overflowWrap = 'anywhere';
|
||||||
|
mirror.style.wordBreak = 'break-all';
|
||||||
|
mirror.style.width = (content.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)) + 'px';
|
||||||
|
const lh = parseFloat(cs.lineHeight) || 1;
|
||||||
|
const starts = [];
|
||||||
|
let total = 0;
|
||||||
|
const n = Math.max(lines.length, 1);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
// A trailing newline yields an empty last line: it still occupies one row.
|
||||||
|
mirror.textContent = lines[i] + '\n';
|
||||||
|
let rows = Math.max(1, Math.round(mirror.getBoundingClientRect().height / lh));
|
||||||
|
starts.push(total);
|
||||||
|
total += rows;
|
||||||
|
}
|
||||||
|
return { starts, total };
|
||||||
|
}
|
||||||
|
|
||||||
function updateGutter() {
|
function updateGutter() {
|
||||||
const lines = content.value.split('\n').length;
|
const lines = content.value.split('\n');
|
||||||
let s = '';
|
const n = Math.max(lines.length, 1);
|
||||||
for (let i = 1; i <= Math.max(lines, 1); i++) s += i + '\n';
|
if (!document.documentElement.hasAttribute('data-wrap')) {
|
||||||
gutter.textContent = s;
|
let s = '';
|
||||||
|
for (let i = 1; i <= n; i++) s += i + '\n';
|
||||||
|
gutter.textContent = s.slice(0, -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { starts, total } = measureRows(lines);
|
||||||
|
gutter.textContent = '';
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
const spans = [];
|
||||||
|
for (let r = 0; r < total; r++) {
|
||||||
|
const c = document.createElement('span');
|
||||||
|
c.className = 'gutline';
|
||||||
|
c.textContent = '\u00a0';
|
||||||
|
spans.push(c);
|
||||||
|
frag.appendChild(c);
|
||||||
|
}
|
||||||
|
gutter.appendChild(frag);
|
||||||
|
for (let j = 0; j < starts.length; j++) spans[starts[j]].textContent = String(j + 1);
|
||||||
}
|
}
|
||||||
content.addEventListener('input', updateGutter);
|
content.addEventListener('input', updateGutter);
|
||||||
|
// #274: the wrap toggle and width changes re-wrap the textarea; re-measure.
|
||||||
|
new MutationObserver(updateGutter).observe(document.documentElement, { attributes: true, attributeFilter: ['data-wrap'] });
|
||||||
|
window.addEventListener('resize', updateGutter);
|
||||||
// #259: the editor scrolls itself; keep the gutter's numbers in step with it.
|
// #259: the editor scrolls itself; keep the gutter's numbers in step with it.
|
||||||
content.addEventListener('scroll', () => { gutter.scrollTop = content.scrollTop; });
|
content.addEventListener('scroll', () => { gutter.scrollTop = content.scrollTop; });
|
||||||
updateGutter();
|
updateGutter();
|
||||||
|
|||||||
Reference in New Issue
Block a user