Skip to content

Commit e4c4215

Browse files
authored
Merge pull request #1 from adnope/security_tweaks
2 parents fbbb3c8 + 1b7966d commit e4c4215

29 files changed

Lines changed: 12293 additions & 24 deletions

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
PORT=8080
22
DATA_DIR=./data
33
SESSION_TTL=30d
4+
COOKIE_SECURE=false
5+
TRUSTED_PROXIES=
46
CHAT_PAGE_SIZE=100
57
HISTORY_PAGE_SIZE=100
68
SEARCH_RESULT_LIMIT=30

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ Environment variables:
7070
PORT=8080
7171
DATA_DIR=./data
7272
SESSION_TTL=30d
73+
COOKIE_SECURE=false
74+
TRUSTED_PROXIES=
7375
CHAT_PAGE_SIZE=100
7476
HISTORY_PAGE_SIZE=100
7577
SEARCH_RESULT_LIMIT=30
@@ -81,6 +83,7 @@ UPLOAD_CONCURRENCY=1
8183
```
8284

8385
Size values accept bytes or `KB`, `MB`, `GB`, `TB`, `KiB`, `MiB`, `GiB`, `TiB`.
86+
`UPLOAD_CONCURRENCY` is capped at 10. Set `COOKIE_SECURE=true` when serving only over HTTPS. Set `TRUSTED_PROXIES` to comma-separated proxy IPs/CIDRs only when a trusted reverse proxy sets forwarding headers.
8487

8588
Create local env file:
8689

cmd/ephemeral/main.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,18 +104,20 @@ func main() {
104104
MaxUploadBytes: cfg.MaxUploadBytes,
105105
TextPreviewMaxBytes: cfg.TextPreviewMaxBytes,
106106
UploadConcurrency: cfg.UploadConcurrency,
107+
CookieSecure: cfg.CookieSecure,
107108
},
108109
)
109110

110111
r := chi.NewRouter()
111112

112113
r.Use(middleware.Recoverer)
113114
r.Use(middleware.RequestID)
114-
r.Use(middleware.RealIP)
115115

116+
r.Use(mw.TrustedRealIP(cfg.TrustedProxies))
117+
r.Use(mw.SecurityHeaders)
116118
r.Use(mw.RequestLogger(logger))
117119
r.Use(mw.RateLimit(100, time.Minute))
118-
r.Use(mw.SessionAuth(sessionRepo, cfg.SessionTTL))
120+
r.Use(mw.SessionAuth(sessionRepo, cfg.SessionTTL, cfg.CookieSecure))
119121

120122
staticSubFS, err := fs.Sub(web.FS, "static")
121123
if err != nil {

docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ services:
1313
- PORT=8080
1414
- DATA_DIR=/app/data
1515
- SESSION_TTL=${SESSION_TTL:-30d}
16+
- COOKIE_SECURE=${COOKIE_SECURE:-false}
17+
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
1618
- CHAT_PAGE_SIZE=${CHAT_PAGE_SIZE:-100}
1719
- HISTORY_PAGE_SIZE=${HISTORY_PAGE_SIZE:-100}
1820
- SEARCH_RESULT_LIMIT=${SEARCH_RESULT_LIMIT:-30}

docs/API.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ Ephemeral provides a minimal set of authenticated endpoints for sharing text, up
66

77
All protected endpoints require a valid `session_token` cookie.
88

9-
Sessions are rolling sessions. When a session is close to expiry, authenticated requests refresh the session expiry and reset the cookie max age. The session TTL is configured with:
9+
Sessions are rolling sessions. When a session is close to expiry, authenticated requests refresh the session expiry and reset the cookie max age. The session TTL and secure-cookie behavior are configured with:
1010

1111
```env
1212
SESSION_TTL=30d
13+
COOKIE_SECURE=false
1314
```
1415

1516
Supported examples:
@@ -37,6 +38,7 @@ UPLOAD_CONCURRENCY=1
3738
```
3839

3940
Size values accept bytes or `KB`, `MB`, `GB`, `TB`, `KiB`, `MiB`, `GiB`, `TiB`.
41+
JSON request bodies for JSON endpoints are limited to 64 KiB. `UPLOAD_CONCURRENCY` is enforced server-side and capped at 10.
4042

4143
## JSON API Conventions
4244

@@ -404,6 +406,7 @@ Returns the file content using `http.ServeFile`.
404406

405407
- Supports filenames with spaces and Unicode characters.
406408
- Rejects unsafe paths such as absolute paths or `..`.
409+
- Active document uploads such as HTML, SVG, XML, and XHTML are served with a sandbox Content Security Policy when opened directly.
407410

408411
---
409412

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.26
44

55
require (
66
github.com/go-chi/chi/v5 v5.1.0
7-
golang.org/x/crypto v0.22.0
7+
golang.org/x/crypto v0.51.0
88
modernc.org/sqlite v1.50.0
99
)
1010

@@ -14,7 +14,7 @@ require (
1414
github.com/mattn/go-isatty v0.0.20 // indirect
1515
github.com/ncruces/go-strftime v1.0.0 // indirect
1616
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
17-
golang.org/x/sys v0.42.0 // indirect
17+
golang.org/x/sys v0.44.0 // indirect
1818
modernc.org/libc v1.72.0 // indirect
1919
modernc.org/mathutil v1.7.1 // indirect
2020
modernc.org/memory v1.11.0 // indirect

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
1414
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
1515
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
1616
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
17-
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
18-
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
17+
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
18+
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
1919
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
2020
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
2121
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
2222
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
2323
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
24-
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
25-
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
24+
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
25+
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
2626
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
2727
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
2828
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=

internal/config/config.go

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,21 @@ package config
33
import (
44
"fmt"
55
"math"
6+
"net/netip"
67
"os"
78
"strconv"
89
"strings"
910
"time"
1011
)
1112

13+
const MaxUploadConcurrency = 10
14+
1215
type Config struct {
1316
Port int
1417
DataDir string
1518
SessionTTL time.Duration
19+
CookieSecure bool
20+
TrustedProxies []netip.Prefix
1621
ChatPageSize int
1722
HistoryPageSize int
1823
SearchResultLimit int
@@ -61,6 +66,18 @@ func Load() (*Config, error) {
6166
cfg.SessionTTL = ttl
6267
}
6368

69+
if err := loadBool("COOKIE_SECURE", &cfg.CookieSecure); err != nil {
70+
return nil, err
71+
}
72+
73+
if v := os.Getenv("TRUSTED_PROXIES"); v != "" {
74+
proxies, err := parseTrustedProxies(v)
75+
if err != nil {
76+
return nil, fmt.Errorf("config: invalid TRUSTED_PROXIES %q: %w", v, err)
77+
}
78+
cfg.TrustedProxies = proxies
79+
}
80+
6481
if err := loadPositiveInt("CHAT_PAGE_SIZE", &cfg.ChatPageSize); err != nil {
6582
return nil, err
6683
}
@@ -82,7 +99,7 @@ func Load() (*Config, error) {
8299
if err := loadPositiveInt("MEDIA_WORKER_COUNT", &cfg.MediaWorkerCount); err != nil {
83100
return nil, err
84101
}
85-
if err := loadPositiveInt("UPLOAD_CONCURRENCY", &cfg.UploadConcurrency); err != nil {
102+
if err := loadBoundedPositiveInt("UPLOAD_CONCURRENCY", &cfg.UploadConcurrency, MaxUploadConcurrency); err != nil {
86103
return nil, err
87104
}
88105

@@ -126,6 +143,31 @@ func loadPositiveInt(name string, target *int) error {
126143
return nil
127144
}
128145

146+
func loadBoundedPositiveInt(name string, target *int, maxValue int) error {
147+
if err := loadPositiveInt(name, target); err != nil {
148+
return err
149+
}
150+
if *target > maxValue {
151+
*target = maxValue
152+
}
153+
return nil
154+
}
155+
156+
func loadBool(name string, target *bool) error {
157+
value := os.Getenv(name)
158+
if value == "" {
159+
return nil
160+
}
161+
162+
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
163+
if err != nil {
164+
return fmt.Errorf("config: invalid %s %q: %w", name, value, err)
165+
}
166+
167+
*target = parsed
168+
return nil
169+
}
170+
129171
func loadByteSize(name string, target *int64) error {
130172
value := os.Getenv(name)
131173
if value == "" {
@@ -144,6 +186,39 @@ func loadByteSize(name string, target *int64) error {
144186
return nil
145187
}
146188

189+
func parseTrustedProxies(value string) ([]netip.Prefix, error) {
190+
parts := strings.Split(value, ",")
191+
proxies := make([]netip.Prefix, 0, len(parts))
192+
for _, part := range parts {
193+
raw := strings.TrimSpace(part)
194+
if raw == "" {
195+
continue
196+
}
197+
198+
if strings.Contains(raw, "/") {
199+
prefix, err := netip.ParsePrefix(raw)
200+
if err != nil {
201+
return nil, err
202+
}
203+
proxies = append(proxies, prefix.Masked())
204+
continue
205+
}
206+
207+
addr, err := netip.ParseAddr(raw)
208+
if err != nil {
209+
return nil, err
210+
}
211+
addr = addr.Unmap()
212+
bits := 32
213+
if addr.Is6() {
214+
bits = 128
215+
}
216+
proxies = append(proxies, netip.PrefixFrom(addr, bits))
217+
}
218+
219+
return proxies, nil
220+
}
221+
147222
func parseDurationWithDays(value string) (time.Duration, error) {
148223
value = strings.TrimSpace(value)
149224
if before, ok := strings.CutSuffix(value, "d"); ok {

internal/config/config_test.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,31 +40,45 @@ func TestLoadRuntimeTuningEnv(t *testing.T) {
4040
t.Setenv("PORT", "9000")
4141
t.Setenv("DATA_DIR", dataDir)
4242
t.Setenv("SESSION_TTL", "2h")
43+
t.Setenv("COOKIE_SECURE", "true")
44+
t.Setenv("TRUSTED_PROXIES", "127.0.0.1, 10.0.0.0/8")
4345
t.Setenv("CHAT_PAGE_SIZE", "25")
4446
t.Setenv("HISTORY_PAGE_SIZE", "50")
4547
t.Setenv("SEARCH_RESULT_LIMIT", "12")
4648
t.Setenv("MAX_UPLOAD_SIZE", "64MiB")
4749
t.Setenv("TEXT_PREVIEW_MAX", "512KiB")
4850
t.Setenv("BODY_INDEX_MAX", "1MiB")
4951
t.Setenv("MEDIA_WORKER_COUNT", "3")
50-
t.Setenv("UPLOAD_CONCURRENCY", "2")
52+
t.Setenv("UPLOAD_CONCURRENCY", "42")
5153

5254
cfg, err := Load()
5355
if err != nil {
5456
t.Fatalf("Load(): %v", err)
5557
}
5658

57-
if cfg.Port != 9000 || cfg.DataDir != dataDir || cfg.SessionTTL != 2*time.Hour {
59+
if cfg.Port != 9000 || cfg.DataDir != dataDir || cfg.SessionTTL != 2*time.Hour || !cfg.CookieSecure {
5860
t.Fatalf("unexpected base config: %#v", cfg)
5961
}
62+
if len(cfg.TrustedProxies) != 2 {
63+
t.Fatalf("TrustedProxies len = %d, want 2", len(cfg.TrustedProxies))
64+
}
6065
if cfg.ChatPageSize != 25 ||
6166
cfg.HistoryPageSize != 50 ||
6267
cfg.SearchResultLimit != 12 ||
6368
cfg.MaxUploadBytes != 64<<20 ||
6469
cfg.TextPreviewMaxBytes != 512<<10 ||
6570
cfg.BodyIndexMaxBytes != 1<<20 ||
6671
cfg.MediaWorkerCount != 3 ||
67-
cfg.UploadConcurrency != 2 {
72+
cfg.UploadConcurrency != MaxUploadConcurrency {
6873
t.Fatalf("unexpected tuning config: %#v", cfg)
6974
}
7075
}
76+
77+
func TestLoadInvalidTrustedProxy(t *testing.T) {
78+
t.Setenv("DATA_DIR", t.TempDir())
79+
t.Setenv("TRUSTED_PROXIES", "not-an-ip")
80+
81+
if _, err := Load(); err == nil {
82+
t.Fatal("Load() error = nil, want invalid trusted proxy error")
83+
}
84+
}

internal/delivery/http/auth.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,11 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
6161

6262
if hasJSONContentType(r) {
6363
var req loginRequest
64-
if err := decodeJSON(r, &req); err != nil {
64+
if err := decodeJSON(w, r, &req); err != nil {
65+
if errors.Is(err, errJSONBodyTooLarge) {
66+
writeJSONError(w, http.StatusRequestEntityTooLarge, "payload_too_large", "JSON body too large")
67+
return
68+
}
6569
writeJSONError(w, http.StatusBadRequest, "validation_error", "invalid JSON body")
6670
return
6771
}
@@ -113,7 +117,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
113117
return
114118
}
115119

116-
http.SetCookie(w, newSessionCookie(result.Token, result.TTL))
120+
http.SetCookie(w, newSessionCookie(result.Token, result.TTL, h.settings.CookieSecure))
117121
if wantsJSON(r) {
118122
writeJSON(w, http.StatusOK, loginResponse{Authenticated: true})
119123
return
@@ -138,13 +142,14 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
138142
http.Redirect(w, r, "/login", http.StatusSeeOther)
139143
}
140144

141-
func newSessionCookie(token string, ttl time.Duration) *http.Cookie {
145+
func newSessionCookie(token string, ttl time.Duration, secure bool) *http.Cookie {
142146
return &http.Cookie{
143147
Name: sessionCookieName,
144148
Value: token,
145149
Path: "/",
146150
MaxAge: int(ttl.Seconds()),
147151
HttpOnly: true,
152+
Secure: secure,
148153
SameSite: http.SameSiteLaxMode,
149154
}
150155
}

0 commit comments

Comments
 (0)