Files
eng-pad-server/internal/server/notebooks_handler.go
Kyle Isom ea9375b6ae 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>
2026-03-24 20:16:26 -07:00

379 lines
11 KiB
Go

package server
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strconv"
"git.wntrmute.dev/kyle/eng-pad-server/internal/render"
"git.wntrmute.dev/kyle/eng-pad-server/internal/share"
"github.com/go-chi/chi/v5"
)
type notebookJSON struct {
ID int64 `json:"id"`
RemoteID int64 `json:"remote_id"`
Title string `json:"title"`
PageSize string `json:"page_size"`
Pages int `json:"pages"`
}
func handleListNotebooks(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
}
rows, err := database.QueryContext(r.Context(),
`SELECT n.id, n.remote_id, n.title, n.page_size,
(SELECT COUNT(*) FROM pages WHERE notebook_id = n.id)
FROM notebooks n WHERE n.user_id = ? ORDER BY n.synced_at DESC`, userID)
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
defer func() { _ = rows.Close() }()
var notebooks []notebookJSON
for rows.Next() {
var nb notebookJSON
if err := rows.Scan(&nb.ID, &nb.RemoteID, &nb.Title, &nb.PageSize, &nb.Pages); err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
notebooks = append(notebooks, nb)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(notebooks)
}
}
func handlePageSVG(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
}
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))
}
}
func handlePageJPG(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
}
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":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(data)
}
}
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
}
internalID, err := verifyNotebookOwnership(database, notebookID, userID)
if err != nil {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
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)
}
}
func handleShareView(database *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
_, err := share.ValidateLink(database, token)
if err != nil {
http.Error(w, `{"error":"link not found or expired"}`, http.StatusGone)
return
}
// For now, return JSON. Web UI will render this in Phase 8.
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
}
func handleSharePageSVG(database *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
notebookID, err := share.ValidateLink(database, token)
if err != nil {
http.Error(w, `{"error":"link not found or expired"}`, http.StatusGone)
return
}
pageNum, err := strconv.Atoi(chi.URLParam(r, "num"))
if err != nil {
http.Error(w, `{"error":"invalid page number"}`, http.StatusBadRequest)
return
}
strokes, pageSize, err := loadPageStrokesByNotebookID(database, notebookID, 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))
}
}
func handleSharePageJPG(database *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
notebookID, err := share.ValidateLink(database, token)
if err != nil {
http.Error(w, `{"error":"link not found or expired"}`, http.StatusGone)
return
}
pageNum, err := strconv.Atoi(chi.URLParam(r, "num"))
if err != nil {
http.Error(w, `{"error":"invalid page number"}`, http.StatusBadRequest)
return
}
strokes, pageSize, err := loadPageStrokesByNotebookID(database, notebookID, 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":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(data)
}
}
func handleSharePDF(database *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
notebookID, err := share.ValidateLink(database, token)
if err != nil {
http.Error(w, `{"error":"link not found or expired"}`, http.StatusGone)
return
}
pages, pageSize, err := loadNotebookPages(database, notebookID)
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)
}
}
// --- helpers ---
// 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 0, fmt.Errorf("not found")
}
return internalID, nil
}
// 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 {
return nil, "", err
}
var pageID int64
err = database.QueryRow(
"SELECT id FROM pages WHERE notebook_id = ? AND page_number = ?",
notebookID, pageNum,
).Scan(&pageID)
if err != nil {
return nil, "", err
}
rows, err := database.Query(
"SELECT pen_size, color, style, point_data, stroke_order FROM strokes WHERE page_id = ? ORDER BY stroke_order",
pageID,
)
if err != nil {
return nil, "", err
}
defer func() { _ = rows.Close() }()
var strokes []render.Stroke
for rows.Next() {
var s render.Stroke
if err := rows.Scan(&s.PenSize, &s.Color, &s.Style, &s.PointData, &s.StrokeOrder); err != nil {
return nil, "", fmt.Errorf("scan stroke: %w", err)
}
strokes = append(strokes, s)
}
return strokes, pageSize, nil
}
func loadNotebookPages(database *sql.DB, notebookID int64) ([]render.Page, string, error) {
var pageSize string
err := database.QueryRow("SELECT page_size FROM notebooks WHERE id = ?", notebookID).Scan(&pageSize)
if err != nil {
return nil, "", err
}
rows, err := database.Query(
"SELECT id, page_number FROM pages WHERE notebook_id = ? ORDER BY page_number",
notebookID,
)
if err != nil {
return nil, "", err
}
defer func() { _ = rows.Close() }()
var pages []render.Page
for rows.Next() {
var pageID int64
var pageNum int
if err := rows.Scan(&pageID, &pageNum); err != nil {
return nil, "", fmt.Errorf("scan page: %w", err)
}
strokeRows, err := database.Query(
"SELECT pen_size, color, style, point_data, stroke_order FROM strokes WHERE page_id = ? ORDER BY stroke_order",
pageID,
)
if err != nil {
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 {
_ = strokeRows.Close()
return nil, "", fmt.Errorf("scan stroke: %w", err)
}
strokes = append(strokes, s)
}
_ = strokeRows.Close()
pages = append(pages, render.Page{PageNumber: pageNum, Strokes: strokes})
}
return pages, pageSize, nil
}