package main // The server never accepts HTML: it takes the same JSON `claude-logbook // --json -s` prints, checks its shape and builds the page itself from the // CLI's own template. The only scripts that run under this domain are the // template's, and the CSP pins them by hash. import ( "bytes" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "regexp" "strings" "time" ) const dataMarker = "__DATA__" // ──────────────────────────────── validation ───────────────────────────── type Block struct { R string `json:"r"` X string `json:"x"` Src string `json:"src,omitempty"` } // Session mirrors a record of sessions.py without its internal keys. // Pointers are the keys that may be null. type Session struct { ID string `json:"id"` P string `json:"p"` B *string `json:"b"` T *string `json:"t"` AI bool `json:"ai"` N bool `json:"n"` E bool `json:"e"` I bool `json:"i"` F *string `json:"f"` L *string `json:"l"` D *float64 `json:"d"` U int `json:"u"` A int `json:"a"` K float64 `json:"k"` V *string `json:"v"` C []Block `json:"c"` H int `json:"h"` O *string `json:"o"` OT *string `json:"ot"` } type Payload struct { S []Session `json:"s"` M []json.RawMessage `json:"m"` } var sessionIDRE = regexp.MustCompile(`^[0-9a-zA-Z-]{1,64}$`) // ParsePayload checks that raw has exactly the shape the template reads and // returns it re-encoded, so nothing the server does not know travels on. func ParsePayload(raw []byte, maxSessions int) (*Payload, []byte, error) { // Keys the server does not know are dropped rather than refused: a newer // CLI may add one, and re-encoding below leaves only what is listed here. dec := json.NewDecoder(bytes.NewReader(raw)) var p Payload if err := dec.Decode(&p); err != nil { return nil, nil, fmt.Errorf("payload: %v", err) } if dec.More() { return nil, nil, errors.New("payload: trailing data") } if len(p.S) == 0 || len(p.S) > maxSessions { return nil, nil, fmt.Errorf("payload: between 1 and %d sessions", maxSessions) } if len(p.M) != 0 { return nil, nil, errors.New("payload: memories are not shared") } for i, s := range p.S { if !sessionIDRE.MatchString(s.ID) { return nil, nil, fmt.Errorf("payload: session %d: bad id", i) } if s.C == nil { return nil, nil, fmt.Errorf("payload: session %d: no c", i) } for j, b := range s.C { switch b.R { case "u", "a", "t", "c", "d": if b.Src != "" { return nil, nil, fmt.Errorf("payload: session %d block %d: src on a %q block", i, j, b.R) } case "i", "v": if b.Src != "" && !validImage(b.Src) { return nil, nil, fmt.Errorf("payload: session %d block %d: not a png, jpeg, gif or webp image", i, j) } default: return nil, nil, fmt.Errorf("payload: session %d block %d: bad r", i, j) } } if s.H < 0 || s.H > len(s.C) { return nil, nil, fmt.Errorf("payload: session %d: bad h", i) } if s.O != nil && !sessionIDRE.MatchString(*s.O) { return nil, nil, fmt.Errorf("payload: session %d: bad o", i) } } p.M = []json.RawMessage{} clean, err := json.Marshal(p) return &p, clean, err } // imageMagic is how each accepted format starts. SVG is not among them: it // is a document that can carry a script. var imageMagic = map[string][]string{ "png": {"\x89PNG\r\n\x1a\n"}, "jpeg": {"\xff\xd8\xff"}, "gif": {"GIF87a", "GIF89a"}, "webp": {"RIFF"}, } // validImage accepts only a base64 data: URL whose bytes really are the // raster format it claims. func validImage(src string) bool { rest, ok := strings.CutPrefix(src, "data:image/") if !ok { return false } kind, data, ok := strings.Cut(rest, ";base64,") if !ok { return false } magics, ok := imageMagic[kind] if !ok { return false } raw, err := base64.StdEncoding.DecodeString(data) if err != nil { return false } if kind == "webp" && (len(raw) < 12 || string(raw[8:12]) != "WEBP") { return false } for _, m := range magics { if bytes.HasPrefix(raw, []byte(m)) { return true } } return false } // ──────────────────────────────── html ─────────────────────────────────── type Page struct { before, after string csp string } var scriptRE = regexp.MustCompile(`(?s)]*)>(.*?)`) // NewPage splits the template at its single marker and computes the CSP that // allows exactly its inline scripts. func NewPage(template string) (*Page, error) { if n := strings.Count(template, dataMarker); n != 1 { return nil, fmt.Errorf("template must contain exactly one %s (found %d)", dataMarker, n) } var hashes []string for _, m := range scriptRE.FindAllStringSubmatch(template, -1) { if strings.Contains(m[1], "application/json") { continue // data, not code: CSP does not apply to it } sum := sha256.Sum256([]byte(m[2])) hashes = append(hashes, "'sha256-"+base64.StdEncoding.EncodeToString(sum[:])+"'") } if len(hashes) == 0 { return nil, errors.New("template has no scripts") } before, after, _ := strings.Cut(template, dataMarker) csp := "default-src 'none'; script-src " + strings.Join(hashes, " ") + "; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; " + "form-action 'none'; frame-ancestors 'none'" return &Page{before: before, after: after, csp: csp}, nil } // EncodeData does what encode_payload in webpage.py does: the HTML parser // ends a