Remove cans feature; add Secure flag to pw_/tok_ cookies
Owner decision 2026-10-16: the cans feature (bundles of pastes, #4) was half-integrated and has zero prod usage; removing it eliminates four audit findings by deletion. Removed: - internal/api/cans.go and all can routes (/api/pastes/can, /api/cans/*, /can/{id}) plus handleCanPage/renderCan - is_can from /api/mine and /api/public responses - store layer: CanRow, can store funcs, paste_cans UNION in listings, cans sweep; reserved slugs 'can'/'cans' released - templates/can.html, can CSS, is_can badges in history.js/mine.js - cans tests (cans_test.go, cans_flow_test.go, cans_expiry_test.go, TestErrorCodeCanValidation) - docs/api.md cans section, README/LICENSE/docker-compose mentions Store migration: drops paste_cans only when empty (non-empty table is kept and logged; prod verified clean 2026-10-16) and drops the dormant pastes.can_id column. Covered by new TestMigrateDropsCansSchema. Security hardening (audit findings): pw_<id> (unlock) and tok_<id> (deletion-token) cookies now carry Secure: true in addition to HttpOnly + SameSite=Lax; all deployments are HTTPS-only. cookie_set_test now asserts Secure. go vet ./... and go test ./... pass.
This commit is contained in:
1 parent
651bc0cb86
commit
e54fbc36ef
20 files changed
+176
-1356
No files matched your search
+4
-141
@@ -1,5 +1,5 @@
|
||||
// Package api implements palette's REST handlers and the HTTP router:
|
||||
// pastes, cans, guess-language, rate limiting middleware, and the admin API.
|
||||
// pastes, guess-language, rate limiting middleware, and the admin API.
|
||||
package api
|
||||
|
||||
import (
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -18,7 +17,6 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
langpkg "palette/internal/lang"
|
||||
"palette/internal/store"
|
||||
"palette/internal/web"
|
||||
)
|
||||
@@ -59,8 +57,7 @@ func writeErrCode(w http.ResponseWriter, status int, code, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg, "code": code})
|
||||
}
|
||||
|
||||
// createErrCode maps a store.CreatePaste/CreateCan error to its UI error
|
||||
// code (#105).
|
||||
// createErrCode maps a store.CreatePaste error to its UI error code (#105).
|
||||
func createErrCode(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrSlugTaken):
|
||||
@@ -103,16 +100,8 @@ func (a *apiServer) routes() http.Handler {
|
||||
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
|
||||
r.Get("/public", a.handleListPublic)
|
||||
r.Post("/guess-language", a.handleGuessLang)
|
||||
r.Post("/pastes/can", a.handleCreateCan)
|
||||
r.Get("/cans/{id}", a.handleGetCan)
|
||||
r.Delete("/cans/{id}", a.handleDeleteCan)
|
||||
r.Get("/cans/{id}/items/{item}", a.handleCanItem)
|
||||
})
|
||||
|
||||
// can page (#4): GET renders, POST unlocks (same flow as pastes)
|
||||
r.Get("/can/{id}", a.handleCanPage)
|
||||
r.Post("/can/{id}", a.handleCanPage)
|
||||
|
||||
// raw + #38 attachment serving
|
||||
r.Get("/f/{aid}/{filename}", a.handleServeAttachment)
|
||||
r.Get("/raw/{id}", a.handleRaw)
|
||||
@@ -189,7 +178,7 @@ func viewerSentCookie(r *http.Request) bool {
|
||||
func setDeletionTokenCookie(w http.ResponseWriter, pasteID, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "tok_" + pasteID, Value: token, Path: "/",
|
||||
MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -442,7 +431,6 @@ func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
|
||||
"id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename),
|
||||
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
|
||||
"custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
|
||||
"is_can": row.IsCan,
|
||||
})
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
|
||||
@@ -462,8 +450,7 @@ func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
|
||||
items = append(items, map[string]any{
|
||||
"id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename),
|
||||
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
|
||||
"custom_slug": store.NullStrPtr(row.CustomSlug),
|
||||
"is_can": row.IsCan,
|
||||
"custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
|
||||
})
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
|
||||
@@ -548,130 +535,6 @@ func safeRawContentType(ct string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
can, err := a.store.GetCan(id)
|
||||
if err != nil || can == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if can.ExpiresAt.Valid && can.ExpiresAt.Int64 < time.Now().Unix() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// #4: password-protected cans go through the same unlock flow as pastes:
|
||||
// the pw_<id> cookie carries an HMAC token bound to this can id. Items
|
||||
// inherit the protection (handleCanItem checks the same cookie).
|
||||
if can.PasswordHash.Valid {
|
||||
h := a.webHandlers()
|
||||
if r.Method == http.MethodPost {
|
||||
if !rateLimitUnlock(can.ID, r) {
|
||||
h.WriteRateLimited(w, 60)
|
||||
return
|
||||
}
|
||||
r.ParseForm()
|
||||
pw := r.FormValue("password")
|
||||
if pw != "" && store.CheckPassword(can.PasswordHash.String, pw) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "pw_" + can.ID, Value: web.UnlockToken(can.ID), Path: "/",
|
||||
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
a.renderCan(w, can)
|
||||
return
|
||||
}
|
||||
h.RenderPage(w, "unlock.html", map[string]any{
|
||||
"Page": "unlock", "ID": can.ID, "Wrong": true,
|
||||
"CreatedAgo": web.AgoString(can.CreatedAt), "CreatedAtUnix": can.CreatedAt,
|
||||
})
|
||||
return
|
||||
}
|
||||
c, err := r.Cookie("pw_" + can.ID)
|
||||
if err != nil || c.Value != web.UnlockToken(can.ID) {
|
||||
h.RenderPage(w, "unlock.html", map[string]any{
|
||||
"Page": "unlock", "ID": can.ID, "Wrong": false,
|
||||
"CreatedAgo": web.AgoString(can.CreatedAt), "CreatedAtUnix": can.CreatedAt,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
a.renderCan(w, can)
|
||||
}
|
||||
|
||||
// renderCan renders the can view page: title/description and items as cards.
|
||||
// Text items expand inline; files link to download.
|
||||
func (a *apiServer) renderCan(w http.ResponseWriter, can *store.CanRow) {
|
||||
h := a.webHandlers()
|
||||
items, err := a.store.ListCanItems(can.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", 500)
|
||||
return
|
||||
}
|
||||
type canItem struct {
|
||||
ID string
|
||||
Title string
|
||||
ContentType string
|
||||
Size string
|
||||
IsFile bool
|
||||
Content string
|
||||
ContentHTML template.HTML
|
||||
Language string
|
||||
}
|
||||
cards := make([]canItem, 0, len(items))
|
||||
totalSize := 0
|
||||
for _, it := range items {
|
||||
totalSize += len(it.Content)
|
||||
isFile := it.ContentType != "text/plain" && !strings.HasPrefix(it.ContentType, "text/")
|
||||
ci := canItem{
|
||||
ID: it.ID,
|
||||
Title: nullStrOr(it.Title, it.ID),
|
||||
ContentType: it.ContentType,
|
||||
Size: web.HumanSize(len(it.Content)),
|
||||
IsFile: isFile,
|
||||
Language: it.Language.String,
|
||||
}
|
||||
if !isFile {
|
||||
ci.ContentHTML = template.HTML(langpkg.HighlightCode(it.Content, it.Language.String))
|
||||
}
|
||||
cards = append(cards, ci)
|
||||
}
|
||||
h.RenderPage(w, "can.html", map[string]any{
|
||||
"Page": "can",
|
||||
"ID": can.ID,
|
||||
"Title": nullStrOr(can.Title, "Untitled can"),
|
||||
"Description": can.Description.String,
|
||||
"HasDescription": can.Description.Valid && can.Description.String != "",
|
||||
"HasPassword": can.PasswordHash.Valid,
|
||||
"Items": cards,
|
||||
"ItemCount": len(cards),
|
||||
"SizeHuman": web.HumanSize(totalSize),
|
||||
"CreatedAgo": web.AgoString(can.CreatedAt),
|
||||
"CreatedAtUnix": can.CreatedAt,
|
||||
"ExpiresAt": can.ExpiresAt.Valid,
|
||||
"ExpiresIn": expiryStringIfValid(can.ExpiresAt),
|
||||
})
|
||||
}
|
||||
|
||||
// expiryStringIfValid formats remaining time for a valid expiry, "" otherwise.
|
||||
func expiryStringIfValid(ns sql.NullInt64) string {
|
||||
if !ns.Valid {
|
||||
return ""
|
||||
}
|
||||
remaining := ns.Int64 - time.Now().Unix()
|
||||
s := remaining
|
||||
switch {
|
||||
case s < 3600:
|
||||
return fmt.Sprintf("%dm", s/60)
|
||||
case s < 86400:
|
||||
return fmt.Sprintf("%dh", s/3600)
|
||||
default:
|
||||
return fmt.Sprintf("%dd", s/86400)
|
||||
}
|
||||
}
|
||||
|
||||
func templateEsc(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">")
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
|
||||
h := a.webHandlers()
|
||||
|
||||
Reference in new issue
Block a user