Merge origin/main (license, compose, #65 leak guard) into cans work
This commit is contained in:
+45
-15
@@ -60,7 +60,9 @@ func (a *apiServer) routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Timeout(30 * time.Second))
|
||||
r.Use(a.limitRequestBody) // #68: hard server-side body cap -> 413
|
||||
r.Use(viewerCookieMiddleware)
|
||||
r.Use(web.SecurityHeaders) // #59: CSP + hardening headers on HTML pages
|
||||
|
||||
// admin (#40): HTML page is open (key entry via form); API is key-guarded
|
||||
r.Get("/admin", a.ui.Handlers().HandleAdminPage)
|
||||
@@ -155,16 +157,43 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var p store.Paste
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
if isBodyTooLarge(err) { // #68: body cut off by MaxBytesReader
|
||||
writeBodyTooLarge(w)
|
||||
return
|
||||
}
|
||||
writeErr(w, 400, "invalid json body")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.Content) == "" {
|
||||
writeErr(w, 400, "content is required")
|
||||
if status, msg := checkContent(p.Content, s.MaxContentBytes); status != 0 {
|
||||
writeErr(w, status, msg)
|
||||
return
|
||||
}
|
||||
if int64(len(p.Content)) > s.MaxContentBytes { // #40: admin-tunable
|
||||
writeErr(w, 413, fmt.Sprintf("content exceeds max %d bytes", s.MaxContentBytes))
|
||||
return
|
||||
// #86: bound free-form metadata at create time
|
||||
if p.Title != nil {
|
||||
t, err := checkTitle(*p.Title)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
p.Title = &t
|
||||
}
|
||||
if p.Language != nil {
|
||||
l, err := checkLanguage(*p.Language)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if l == "" {
|
||||
p.Language = nil
|
||||
} else {
|
||||
p.Language = &l
|
||||
}
|
||||
}
|
||||
if p.BurnAfterReads != nil { // #68: reject negative read budgets
|
||||
if err := parseBurnAfterReads(*p.BurnAfterReads); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
// #40: admin-configurable default expiry
|
||||
if (p.ExpiresIn == nil || *p.ExpiresIn == "") && s.DefaultExpiry != "" {
|
||||
@@ -209,6 +238,13 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if row.PasswordHash.Valid {
|
||||
// #81: every password verification (header, query param, or empty)
|
||||
// goes through the same per-IP+paste unlock limiter as the POST form
|
||||
// path, so brute-force via GET ?password= or X-Paste-Password gets 429.
|
||||
if !rateLimitUnlock(row.ID, r) {
|
||||
writeRateLimited(w, 60)
|
||||
return
|
||||
}
|
||||
// require password via header or query
|
||||
pw := r.Header.Get("X-Paste-Password")
|
||||
if pw == "" {
|
||||
@@ -293,11 +329,8 @@ func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"total": 0, "items": []any{}})
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
limit := parseLimit(r, 50, 100)
|
||||
offset := parseOffset(r)
|
||||
rows, total, err := a.store.ListMine(vid, limit, offset)
|
||||
if err != nil {
|
||||
writeErr(w, 500, "db error")
|
||||
@@ -317,11 +350,8 @@ func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
limit := parseLimit(r, 25, 100)
|
||||
offset := parseOffset(r)
|
||||
rows, total, err := a.store.ListPublic(limit, offset)
|
||||
if err != nil {
|
||||
writeErr(w, 500, "db error")
|
||||
|
||||
Reference in New Issue
Block a user