diff options
| author | Elvis Claros Castro <elvis@claros.ar> | 2026-10-03 23:29:44 -0300 |
|---|---|---|
| committer | Elvis Claros Castro <elvis@claros.ar> | 2026-10-03 23:29:44 -0300 |
| commit | 80ea7b675e918b930af01b82b60583d474bc0105 (patch) | |
| tree | 0d73b3a65344e810be61263dfa86490eef68510b /server | |
| parent | f546a8a1578bef9fbc144580b84f51e3e90480b1 (diff) | |
| download | claude-logbook-main.tar.gz claude-logbook-main.zip | |
Diffstat (limited to 'server')
| -rw-r--r-- | server/README.md | 6 | ||||
| -rw-r--r-- | server/home.html | 2 | ||||
| -rw-r--r-- | server/main.go | 4 | ||||
| -rw-r--r-- | server/render.go | 72 | ||||
| -rw-r--r-- | server/server_test.go | 30 |
5 files changed, 105 insertions, 9 deletions
diff --git a/server/README.md b/server/README.md index 8e2de37..a74cb5c 100644 --- a/server/README.md +++ b/server/README.md @@ -26,6 +26,8 @@ creation can replace or delete it. The server keeps only the secret's SHA-256. two inline scripts by hash: no inline handlers, no external anything. - Share ids are 10 base62 characters from `crypto/rand`; pages carry `X-Robots-Tag: noindex` and `Referrer-Policy: no-referrer`. +- Images (`--images`) are accepted only as base64 `data:` URLs of PNG, JPEG, + GIF or WebP whose bytes match the format they claim; SVG never. - Limits: request body (`MAX_BYTES`), sessions per share (`MAX_SESSIONS`), and a token bucket per client IP for creations and for updates/deletes. - `X-Forwarded-For` is believed only when the connection comes from @@ -41,7 +43,7 @@ Content is **not encrypted**: whoever runs the server can read the shares. | `ADDR` | `-addr` | `:8181` | | `DATA_DIR` | `-data` | `/data` | | `PUBLIC_URL` | `-public-url` | `https://claude-logbook.all.ar` | -| `MAX_BYTES` | `-max-bytes` | `10485760` (10 MB) | +| `MAX_BYTES` | `-max-bytes` | `20971520` (20 MB) | | `MAX_SESSIONS` | `-max-sessions` | `1` | | `TRUSTED_PROXY` | `-trusted-proxy` | empty: trust no proxy (IPs or CIDRs, comma separated) | | `CREATES_PER_HOUR` | `-creates-per-hour` | `10` per IP | @@ -93,7 +95,7 @@ certificate (Let's Encrypt) and *Force SSL*. Add this under *Advanced* so uploads up to `MAX_BYTES` get through: ```nginx -client_max_body_size 10m; +client_max_body_size 20m; ``` `TRUSTED_PROXY` must be the address the connections from NPM arrive from. diff --git a/server/home.html b/server/home.html index 6ae6ad1..ffc559e 100644 --- a/server/home.html +++ b/server/home.html @@ -59,6 +59,7 @@ claude-logbook <span class="c"># table of every session</span> <p>Pick a session by its number in the table or by a prefix of its UUID, then share it:</p> <pre><code>claude-logbook -s 3 --share <span class="c"># asks before uploading</span> claude-logbook -s 5d10f1ee --share --expire 7d +claude-logbook -s 3 --share --images <span class="c"># with its images</span> claude-logbook --shares <span class="c"># what you have shared</span> claude-logbook --unshare 5d10f1ee <span class="c"># take it down</span></code></pre> <p>Sharing the same session again updates it in place: the link stays the same.</p> @@ -78,6 +79,7 @@ claude-logbook --unshare 5d10f1ee <span class="c"># take it down</span> </div> <ul> <li><code>--share</code> warns if it spots things that look like API keys, tokens or private keys.</li> + <li>Images go only with <code>--images</code>, and nothing checks what they show: look at them first.</li> <li>Shares expire after 30 days unless you pick <code>--expire 1d</code>, <code>7d</code> or <code>90d</code>.</li> <li>Only the machine that created a share holds its secret and can update or delete it (<code>~/.local/state/claude-logbook/shares.json</code>).</li> diff --git a/server/main.go b/server/main.go index 617a9b1..ab3c76d 100644 --- a/server/main.go +++ b/server/main.go @@ -392,7 +392,7 @@ func main() { flag.StringVar(&cfg.Addr, "addr", env("ADDR", ":8181"), "listen address") flag.StringVar(&cfg.DataDir, "data", env("DATA_DIR", "/data"), "where shares are stored") flag.StringVar(&cfg.PublicURL, "public-url", env("PUBLIC_URL", "https://claude-logbook.all.ar"), "base of the URLs handed out") - flag.Int64Var(&cfg.MaxBytes, "max-bytes", envInt("MAX_BYTES", 10<<20), "largest request body") + flag.Int64Var(&cfg.MaxBytes, "max-bytes", envInt("MAX_BYTES", 20<<20), "largest request body") maxSessions := flag.Int64("max-sessions", envInt("MAX_SESSIONS", 1), "sessions per share") flag.StringVar(&cfg.TrustedProxy, "trusted-proxy", env("TRUSTED_PROXY", ""), "IPs/CIDRs whose X-Forwarded-For is believed (comma separated)") creates := flag.Int64("creates-per-hour", envInt("CREATES_PER_HOUR", 10), "new shares per IP and hour") @@ -421,7 +421,7 @@ func main() { Addr: cfg.Addr, Handler: srv.Routes(), ReadHeaderTimeout: 10 * time.Second, - ReadTimeout: 60 * time.Second, + ReadTimeout: 180 * time.Second, // 20 MB of images on a slow uplink WriteTimeout: 60 * time.Second, IdleTimeout: 120 * time.Second, MaxHeaderBytes: 16 << 10, diff --git a/server/render.go b/server/render.go index 2c72c1a..6acb8aa 100644 --- a/server/render.go +++ b/server/render.go @@ -22,8 +22,9 @@ const dataMarker = "__DATA__" // ──────────────────────────────── validation ───────────────────────────── type Block struct { - R string `json:"r"` - X string `json:"x"` + R string `json:"r"` + X string `json:"x"` + Src string `json:"src,omitempty"` } // Session mirrors a record of sessions.py without its internal keys. @@ -84,7 +85,16 @@ func ParsePayload(raw []byte, maxSessions int) (*Payload, []byte, error) { return nil, nil, fmt.Errorf("payload: session %d: no c", i) } for j, b := range s.C { - if b.R != "u" && b.R != "a" && b.R != "t" && b.R != "c" && b.R != "d" { + 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) } } @@ -100,6 +110,45 @@ func ParsePayload(raw []byte, maxSessions int) (*Payload, []byte, error) { 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 { @@ -169,6 +218,10 @@ func fmtStamp(s *string) string { return t.UTC().Format("2006-01-02 15:04 UTC") } +// lineBlock are the blocks printed as one "> " line; a run of them goes +// together, after a blank line. +var lineBlock = map[string]bool{"t": true, "d": true, "i": true, "v": true} + // RenderText is the transcript in plain text, in the order the terminal // shows it with `claude-logbook -s`. func RenderText(p *Payload) []byte { @@ -208,14 +261,23 @@ func RenderText(p *Payload) []byte { } switch c.R { case "d": - if prev != "t" && prev != "d" { + if !lineBlock[prev] { b.WriteString("\n") } fmt.Fprintf(&b, "> cd: %s\n", c.X) case "c": b.WriteString("\n--- context compacted here ---\n") + case "i", "v": + if !lineBlock[prev] { + b.WriteString("\n") + } + what := "image attached" + if c.R == "v" { + what = "image from the tool" + } + fmt.Fprintf(&b, "> %s\n", what) case "t": - if prev != "t" && prev != "d" { + if !lineBlock[prev] { b.WriteString("\n") } fmt.Fprintf(&b, "> tool: %s\n", strings.ReplaceAll(c.X, "\n", " ")) diff --git a/server/server_test.go b/server/server_test.go index 1ad36ee..9adfa3a 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/base64" "encoding/json" "net/http" "net/http/httptest" @@ -145,6 +146,35 @@ func TestBranchAndDirectoryChange(t *testing.T) { } } +func TestImages(t *testing.T) { + h := newHarness(t, nil) + png := "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nrest")) + withImg := strings.Replace(session, `"c":[`, `"c":[{"r":"i","x":"[image]","src":"`+png+`"},{"r":"v","x":"[image]"},`, 1) + r := h.create(`{"payload":` + payload(withImg) + `}`) + if js := h.do("GET", "/share/"+r.ID+".json", "", "").Body.String(); !strings.Contains(js, png) { + t.Fatalf("image lost: %s", js) + } + txt := h.do("GET", "/share/"+r.ID+".txt", "", "").Body.String() + if !strings.Contains(txt, "> image attached\n> image from the tool\n") { + t.Fatalf("txt: %s", txt) + } + + svg := "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte("<svg onload=alert(1)>")) + fake := "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("<script>")) + for name, block := range map[string]string{ + "svg": `{"r":"i","x":"","src":"` + svg + `"}`, + "wrong bytes": `{"r":"i","x":"","src":"` + fake + `"}`, + "not base64": `{"r":"i","x":"","src":"data:image/png;base64,%%%"}`, + "remote": `{"r":"i","x":"","src":"https://evil.test/x.png"}`, + "src on a text": `{"r":"u","x":"","src":"` + png + `"}`, + } { + bad := strings.Replace(session, `"c":[`, `"c":[`+block+`,`, 1) + if rec := h.do("POST", "/api/shares", `{"payload":`+payload(bad)+`}`, ""); rec.Code != 400 { + t.Errorf("%s: %d %s", name, rec.Code, rec.Body) + } + } +} + func TestUpdateAndDeleteNeedTheSecret(t *testing.T) { h := newHarness(t, nil) r := h.create(`{"payload":` + payload() + `}`) |