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:
2026-03-24 20:16:26 -07:00
parent 51dd5a6ca3
commit ea9375b6ae
13 changed files with 478 additions and 74 deletions

View File

@@ -22,7 +22,10 @@ func RenderJPG(pageSize string, strokes []Stroke, quality int) ([]byte, error) {
// Draw strokes
for _, s := range strokes {
points := decodePoints(s.PointData)
points, err := decodePoints(s.PointData)
if err != nil {
continue
}
if len(points) < 4 {
continue
}

View File

@@ -12,8 +12,8 @@ type Page struct {
}
// RenderPDF generates a minimal PDF document from pages.
// Uses raw PDF operators no external library needed.
func RenderPDF(pageSize string, pages []Page) []byte {
// Uses raw PDF operators -- no external library needed.
func RenderPDF(pageSize string, pages []Page) ([]byte, error) {
w, h := PageSizePt(pageSize)
var objects []string
@@ -30,7 +30,7 @@ func RenderPDF(pageSize string, pages []Page) []byte {
objects = append(objects, fmt.Sprintf("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"))
pdf.WriteString(objects[0])
// Pages object (object 2) we'll write this after we know the page objects
// Pages object (object 2) -- we'll write this after we know the page objects
pagesObjOffset := pdf.Len()
pagesObjPlaceholder := strings.Repeat(" ", 200) + "\n"
pdf.WriteString(pagesObjPlaceholder)
@@ -47,7 +47,10 @@ func RenderPDF(pageSize string, pages []Page) []byte {
// Content stream
var stream strings.Builder
for _, s := range page.Strokes {
points := decodePoints(s.PointData)
points, err := decodePoints(s.PointData)
if err != nil {
continue
}
if len(points) < 4 {
continue
}
@@ -89,12 +92,12 @@ func RenderPDF(pageSize string, pages []Page) []byte {
// Now write the Pages object at its placeholder position
pagesObj := fmt.Sprintf("2 0 obj\n<< /Type /Pages /Kids [%s] /Count %d >>\nendobj\n",
strings.Join(pageRefs, " "), len(pages))
// Overwrite placeholder we need to rebuild the PDF string
// Overwrite placeholder -- we need to rebuild the PDF string
pdfStr := pdf.String()
pdfStr = pdfStr[:pagesObjOffset] + pagesObj + strings.Repeat(" ", len(pagesObjPlaceholder)-len(pagesObj)) + pdfStr[pagesObjOffset+len(pagesObjPlaceholder):]
// Rebuild with correct offsets for xref
// For simplicity, just return the PDF bytes most viewers handle minor xref issues
// For simplicity, just return the PDF bytes -- most viewers handle minor xref issues
var final strings.Builder
final.WriteString(pdfStr)
@@ -102,7 +105,7 @@ func RenderPDF(pageSize string, pages []Page) []byte {
xrefOffset := final.Len()
fmt.Fprintf(&final, "xref\n0 %d\n", nextObj)
fmt.Fprintf(&final, "0000000000 65535 f \n")
// For a proper PDF we'd need exact offsets skip for now
// For a proper PDF we'd need exact offsets -- skip for now
for i := 0; i < nextObj-1; i++ {
fmt.Fprintf(&final, "%010d 00000 n \n", 0)
}
@@ -110,5 +113,5 @@ func RenderPDF(pageSize string, pages []Page) []byte {
fmt.Fprintf(&final, "trailer\n<< /Size %d /Root 1 0 R >>\n", nextObj)
fmt.Fprintf(&final, "startxref\n%d\n%%%%EOF\n", xrefOffset)
return []byte(final.String())
return []byte(final.String()), nil
}

View File

@@ -25,7 +25,10 @@ func TestRenderSVG(t *testing.T) {
},
}
svg := RenderSVG("REGULAR", strokes)
svg, err := RenderSVG("REGULAR", strokes)
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(svg, "<svg") {
t.Fatal("expected SVG element")
@@ -51,7 +54,10 @@ func TestRenderSVGDashed(t *testing.T) {
},
}
svg := RenderSVG("REGULAR", strokes)
svg, err := RenderSVG("REGULAR", strokes)
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(svg, "stroke-dasharray") {
t.Fatal("expected stroke-dasharray for dashed line")
}
@@ -67,7 +73,10 @@ func TestRenderSVGArrow(t *testing.T) {
},
}
svg := RenderSVG("REGULAR", strokes)
svg, err := RenderSVG("REGULAR", strokes)
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(svg, "<line") {
t.Fatal("expected arrow head lines")
}
@@ -111,7 +120,10 @@ func TestRenderPDF(t *testing.T) {
},
}
data := RenderPDF("REGULAR", pages)
data, err := RenderPDF("REGULAR", pages)
if err != nil {
t.Fatalf("render: %v", err)
}
if len(data) == 0 {
t.Fatal("expected non-empty PDF")
}
@@ -130,3 +142,29 @@ func TestPageSizePt(t *testing.T) {
t.Fatalf("LARGE: got %v x %v, want 792 x 1224", w, h)
}
}
func TestDecodePointsInvalidLength(t *testing.T) {
// Not a multiple of 4
data := []byte{1, 2, 3}
_, err := decodePoints(data)
if err == nil {
t.Fatal("expected error for non-multiple-of-4 data")
}
}
func TestDecodePointsNaN(t *testing.T) {
data := make([]byte, 4)
binary.LittleEndian.PutUint32(data, math.Float32bits(float32(math.NaN())))
_, err := decodePoints(data)
if err == nil {
t.Fatal("expected error for NaN point data")
}
}
func TestDecodePointsOversize(t *testing.T) {
data := make([]byte, maxPointDataSize+4)
_, err := decodePoints(data)
if err == nil {
t.Fatal("expected error for oversized point data")
}
}

View File

@@ -9,6 +9,9 @@ import (
const canonicalToPDF = 72.0 / 300.0 // 0.24
// maxPointDataSize is the maximum allowed size of point data (4 MB).
const maxPointDataSize = 4 * 1024 * 1024
// PageSizePt returns the page dimensions in PDF points (72 DPI).
func PageSizePt(pageSize string) (float64, float64) {
switch pageSize {
@@ -20,15 +23,15 @@ func PageSizePt(pageSize string) (float64, float64) {
}
type Stroke struct {
PenSize float32
Color int32
Style string
PointData []byte
PenSize float32
Color int32
Style string
PointData []byte
StrokeOrder int
}
// RenderSVG renders a page's strokes as an SVG document.
func RenderSVG(pageSize string, strokes []Stroke) string {
func RenderSVG(pageSize string, strokes []Stroke) (string, error) {
w, h := PageSizePt(pageSize)
var b strings.Builder
@@ -39,7 +42,10 @@ func RenderSVG(pageSize string, strokes []Stroke) string {
b.WriteString("\n")
for _, s := range strokes {
points := decodePoints(s.PointData)
points, err := decodePoints(s.PointData)
if err != nil {
continue
}
if len(points) < 4 {
continue
}
@@ -71,7 +77,7 @@ func RenderSVG(pageSize string, strokes []Stroke) string {
}
b.WriteString("</svg>")
return b.String()
return b.String(), nil
}
func renderArrowHeads(x1, y1, x2, y2 float64, style string, penW float64, color string) string {
@@ -109,14 +115,28 @@ func renderArrowHeads(x1, y1, x2, y2 float64, style string, penW float64, color
return b.String()
}
func decodePoints(data []byte) []float64 {
// decodePoints decodes a byte slice of little-endian float32 values into float64.
// Returns an error if the data length is not a multiple of 4, exceeds maxPointDataSize,
// or contains NaN/Inf values.
func decodePoints(data []byte) ([]float64, error) {
if len(data)%4 != 0 {
return nil, fmt.Errorf("point data length %d is not a multiple of 4", len(data))
}
if len(data) > maxPointDataSize {
return nil, fmt.Errorf("point data size %d exceeds maximum %d", len(data), maxPointDataSize)
}
count := len(data) / 4
points := make([]float64, count)
points := make([]float64, 0, count)
for i := 0; i < count; i++ {
bits := binary.LittleEndian.Uint32(data[i*4 : (i+1)*4])
points[i] = float64(math.Float32frombits(bits))
v := float64(math.Float32frombits(bits))
if math.IsNaN(v) || math.IsInf(v, 0) {
return nil, fmt.Errorf("point data contains NaN or Inf at index %d", i)
}
points = append(points, v)
}
return points
return points, nil
}
func colorToCSS(argb int32) string {