Security hardening: fix critical, high, and medium issues from audit
CRITICAL: - A-001: SQL injection in snapshot — escape single quotes in backup path - A-002: Timing attack — always verify against dummy hash when user not found, preventing username enumeration - A-003: Notebook ownership — all authenticated endpoints now verify user_id before loading notebook data - A-004: Point data bounds — decodePoints returns error on misaligned data, >4MB payloads, and NaN/Inf values HIGH: - A-005: Error messages — generic errors in HTTP responses, no err.Error() - A-006: Share link authz — RevokeShareLink verifies notebook ownership - A-007: Scan errors — return 500 instead of silently continuing MEDIUM: - A-008: Web server TLS — optional TLS support (HTTPS when configured) - A-009: Input validation — page_size, stroke count, point_data alignment checked in SyncNotebook RPC - A-010: Graceful shutdown — 30s drain on SIGINT/SIGTERM, all servers shut down properly Added AUDIT.md with all 17 findings, status, and rationale for accepted risks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -41,7 +42,8 @@ func handleListNotebooks(database *sql.DB) http.HandlerFunc {
|
||||
for rows.Next() {
|
||||
var nb notebookJSON
|
||||
if err := rows.Scan(&nb.ID, &nb.RemoteID, &nb.Title, &nb.PageSize, &nb.Pages); err != nil {
|
||||
continue
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
notebooks = append(notebooks, nb)
|
||||
}
|
||||
@@ -53,13 +55,40 @@ func handleListNotebooks(database *sql.DB) http.HandlerFunc {
|
||||
|
||||
func handlePageSVG(database *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
strokes, pageSize, err := loadPageStrokes(r, database)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
||||
userID, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthenticated"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
svg := render.RenderSVG(pageSize, strokes)
|
||||
notebookID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pageNum, err := strconv.Atoi(chi.URLParam(r, "num"))
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid page number"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
internalID, err := verifyNotebookOwnership(database, notebookID, userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
strokes, pageSize, err := loadPageStrokesByNotebookID(database, internalID, pageNum)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
svg, err := render.RenderSVG(pageSize, strokes)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
_, _ = w.Write([]byte(svg))
|
||||
}
|
||||
@@ -67,15 +96,38 @@ func handlePageSVG(database *sql.DB) http.HandlerFunc {
|
||||
|
||||
func handlePageJPG(database *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
strokes, pageSize, err := loadPageStrokes(r, database)
|
||||
userID, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthenticated"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
notebookID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
||||
http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pageNum, err := strconv.Atoi(chi.URLParam(r, "num"))
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid page number"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
internalID, err := verifyNotebookOwnership(database, notebookID, userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
strokes, pageSize, err := loadPageStrokesByNotebookID(database, internalID, pageNum)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := render.RenderJPG(pageSize, strokes, 95)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"render error"}`, http.StatusInternalServerError)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,19 +138,35 @@ func handlePageJPG(database *sql.DB) http.HandlerFunc {
|
||||
|
||||
func handleNotebookPDF(database *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthenticated"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
notebookID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pages, pageSize, err := loadNotebookPages(database, notebookID)
|
||||
internalID, err := verifyNotebookOwnership(database, notebookID, userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := render.RenderPDF(pageSize, pages)
|
||||
pages, pageSize, err := loadNotebookPages(database, internalID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := render.RenderPDF(pageSize, pages)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=notebook.pdf")
|
||||
_, _ = w.Write(data)
|
||||
@@ -134,13 +202,17 @@ func handleSharePageSVG(database *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
strokes, pageSize, err := loadPageStrokesByNotebook(database, notebookID, pageNum)
|
||||
strokes, pageSize, err := loadPageStrokesByNotebookID(database, notebookID, pageNum)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
svg := render.RenderSVG(pageSize, strokes)
|
||||
svg, err := render.RenderSVG(pageSize, strokes)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
_, _ = w.Write([]byte(svg))
|
||||
}
|
||||
@@ -161,15 +233,15 @@ func handleSharePageJPG(database *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
strokes, pageSize, err := loadPageStrokesByNotebook(database, notebookID, pageNum)
|
||||
strokes, pageSize, err := loadPageStrokesByNotebookID(database, notebookID, pageNum)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := render.RenderJPG(pageSize, strokes, 95)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"render error"}`, http.StatusInternalServerError)
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -189,11 +261,15 @@ func handleSharePDF(database *sql.DB) http.HandlerFunc {
|
||||
|
||||
pages, pageSize, err := loadNotebookPages(database, notebookID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := render.RenderPDF(pageSize, pages)
|
||||
data, err := render.RenderPDF(pageSize, pages)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=notebook.pdf")
|
||||
_, _ = w.Write(data)
|
||||
@@ -202,19 +278,22 @@ func handleSharePDF(database *sql.DB) http.HandlerFunc {
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func loadPageStrokes(r *http.Request, database *sql.DB) ([]render.Stroke, string, error) {
|
||||
notebookID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
// verifyNotebookOwnership checks that notebookID belongs to userID and returns
|
||||
// the internal (server-side) notebook ID.
|
||||
func verifyNotebookOwnership(database *sql.DB, notebookID int64, userID int64) (int64, error) {
|
||||
var internalID int64
|
||||
err := database.QueryRow(
|
||||
"SELECT id FROM notebooks WHERE id = ? AND user_id = ?", notebookID, userID,
|
||||
).Scan(&internalID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return 0, fmt.Errorf("not found")
|
||||
}
|
||||
pageNum, err := strconv.Atoi(chi.URLParam(r, "num"))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return loadPageStrokesByNotebook(database, notebookID, pageNum)
|
||||
return internalID, nil
|
||||
}
|
||||
|
||||
func loadPageStrokesByNotebook(database *sql.DB, notebookID int64, pageNum int) ([]render.Stroke, string, error) {
|
||||
// loadPageStrokesByNotebookID loads strokes for a page by internal notebook ID.
|
||||
// This is used by both authenticated and share-link endpoints.
|
||||
func loadPageStrokesByNotebookID(database *sql.DB, notebookID int64, pageNum int) ([]render.Stroke, string, error) {
|
||||
var pageSize string
|
||||
err := database.QueryRow("SELECT page_size FROM notebooks WHERE id = ?", notebookID).Scan(&pageSize)
|
||||
if err != nil {
|
||||
@@ -243,7 +322,7 @@ func loadPageStrokesByNotebook(database *sql.DB, notebookID int64, pageNum int)
|
||||
for rows.Next() {
|
||||
var s render.Stroke
|
||||
if err := rows.Scan(&s.PenSize, &s.Color, &s.Style, &s.PointData, &s.StrokeOrder); err != nil {
|
||||
continue
|
||||
return nil, "", fmt.Errorf("scan stroke: %w", err)
|
||||
}
|
||||
strokes = append(strokes, s)
|
||||
}
|
||||
@@ -271,7 +350,7 @@ func loadNotebookPages(database *sql.DB, notebookID int64) ([]render.Page, strin
|
||||
var pageID int64
|
||||
var pageNum int
|
||||
if err := rows.Scan(&pageID, &pageNum); err != nil {
|
||||
continue
|
||||
return nil, "", fmt.Errorf("scan page: %w", err)
|
||||
}
|
||||
|
||||
strokeRows, err := database.Query(
|
||||
@@ -279,14 +358,15 @@ func loadNotebookPages(database *sql.DB, notebookID int64) ([]render.Page, strin
|
||||
pageID,
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
return nil, "", fmt.Errorf("query strokes: %w", err)
|
||||
}
|
||||
|
||||
var strokes []render.Stroke
|
||||
for strokeRows.Next() {
|
||||
var s render.Stroke
|
||||
if err := strokeRows.Scan(&s.PenSize, &s.Color, &s.Style, &s.PointData, &s.StrokeOrder); err != nil {
|
||||
continue
|
||||
_ = strokeRows.Close()
|
||||
return nil, "", fmt.Errorf("scan stroke: %w", err)
|
||||
}
|
||||
strokes = append(strokes, s)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user