edge: carlos.js fix round 1 — sanitiser and beacon-boundary fixes
Fixes to internal/edge/carlosjs.go's carlosJS constant from the reviewer's
first pass (Go handler serveCarlosJS untouched, approved as-is):
- The base64-run rule matched ordinary URL paths and stack-frame URLs
because '/' was in its character class; dropped it, keeping '+' and the
'=' padding.
- san() sliced to 8192 chars BEFORE running the redaction replacements, so
a secret straddling that boundary could leak its head; reordered to
sanitise the full string first, then truncate.
- fields() called JSON.stringify(v) unguarded, and err()/track's bodies
ran outside any try, so a circular value (e.g. carlos.track('click',
{el: e.target})) threw into the member's page; wrapped the per-value
stringify in try/catch (skip that entry on failure) and wrapped err()
and track's bodies in try{}catch(e){}.
- The fetch fallback had no .catch, so a failed beacon became an
unhandledrejection into the snippet's own handler into another failing
fetch, forever; added .catch(function(){}). navigator.sendBeacon
returning false (queue full / over its payload limit) now falls through
to the fetch path instead of being dropped silently.
- location.pathname is now sanitised (san(...).slice(0,1024)) in both
beacon shapes, not sent raw.
- fields() now tracks a 4096-byte budget across entries so a maximal error
beacon (8192 stack + 1024 name + 64 kind + 1024 path + fields) can't
exceed the handler's 16 KB body cap.
- Added a test seam, window.carlos.__san = san, and
TestCarlosJSSanitizer in carlosjs_test.go: skips without node on PATH,
otherwise stubs window/navigator/location/document enough for the IIFE
to load, evals carlosJS under real node, and runs a table of inputs
(email in a sentence, a Bearer token, a query-string token param, a
40-char hex digest, a 40-char mixed base64-ish run with '+', an
unredacted CDN stack-frame URL, an ordinary long path, and a 9000-char
string with a trailing email) through window.carlos.__san, comparing
against node's actual output.
Verified under node (not separately committed as tests, per the fix
instructions): a circular carlos.track() field no longer throws; a
rejected fallback fetch no longer cascades into a retry loop.
go vet ./internal/edge/, go test ./internal/edge/ -run 'CarlosJS|Reserved',
and go test ./internal/edge/ (whole package) all pass; gofmt -l
internal/edge/ is clean.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 files changed,
+104
−9
internal/edge/carlosjs.go+9 −9internal/edge/carlosjs_test.go+95 −0
diff --git a/internal/edge/carlosjs.go b/internal/edge/carlosjs.go| index 99ff2c5f9..331458a72 100644 |
| --- a/internal/edge/carlosjs.go |
| +++ b/internal/edge/carlosjs.go |
| @@ -21,23 +21,23 @@ import ( |
| const carlosJS = `(function(){ |
| if(window.carlos&&window.carlos.__v)return; |
| var E='/__carlos/e',A='/__carlos/a'; |
| -function san(s){s=String(s==null?'':s);if(s.length>8192)s=s.slice(0,8192); |
| +function san(s){s=String(s==null?'':s); |
| s=s.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,'<email>'); |
| s=s.replace(/\b(bearer|basic)\s+[A-Za-z0-9._~+\/=-]+/gi,'$1 <redacted>'); |
| s=s.replace(/\b(token|key|secret|password|passwd|pwd|api_key|apikey|access_token|auth)("?\s*[=:]\s*"?)[^\s&"',;]+/gi,'$1$2<redacted>'); |
| s=s.replace(/\b[A-Fa-f0-9]{32,}\b/g,'<hex>'); |
| -s=s.replace(/\b(?=[A-Za-z0-9+\/]*\d)(?=[A-Za-z0-9+\/]*[A-Z])(?=[A-Za-z0-9+\/]*[a-z])[A-Za-z0-9+\/]{32,}={0,2}/g,'<hex>'); |
| +s=s.replace(/\b(?=[A-Za-z0-9+]*\d)(?=[A-Za-z0-9+]*[A-Z])(?=[A-Za-z0-9+]*[a-z])[A-Za-z0-9+]{32,}={0,2}/g,'<hex>'); |
| +if(s.length>8192)s=s.slice(0,8192); |
| return s;} |
| function send(u,b){try{var s=JSON.stringify(b); |
| -if(navigator.sendBeacon){navigator.sendBeacon(u,new Blob([s],{type:'application/json'}));} |
| -else{fetch(u,{method:'POST',body:s,headers:{'Content-Type':'application/json'},keepalive:true,credentials:'omit'});}}catch(e){}} |
| -function fields(f){var o={},n=0;if(f&&typeof f==='object'){for(var k in f){if(!Object.prototype.hasOwnProperty.call(f,k))continue;if(n++>=32)break;var v=f[k];if(v==null)continue; |
| -o[String(k).slice(0,64)]=san(typeof v==='object'?JSON.stringify(v):v).slice(0,256);}}return o;} |
| -function err(kind,name,stack,f){send(E,{kind:String(kind||'Error').slice(0,64),name:san(name).slice(0,1024),stack:san(stack||'').slice(0,8192),path:location.pathname,fields:fields(f)});} |
| +var ok=navigator.sendBeacon&&navigator.sendBeacon(u,new Blob([s],{type:'application/json'})); |
| +if(!ok){fetch(u,{method:'POST',body:s,headers:{'Content-Type':'application/json'},keepalive:true,credentials:'omit'}).catch(function(){});}}catch(e){}} |
| +function fields(f){var o={},n=0,budget=4096;if(f&&typeof f==='object'){for(var k in f){if(!Object.prototype.hasOwnProperty.call(f,k))continue;if(n++>=32)break;var v=f[k];if(v==null)continue;var vs;try{vs=typeof v==='object'?JSON.stringify(v):v;}catch(e){continue;}vs=san(vs).slice(0,256);var key=String(k).slice(0,64);if(key.length+vs.length>budget)break;budget-=key.length+vs.length;o[key]=vs;}}return o;} |
| +function err(kind,name,stack,f){try{send(E,{kind:String(kind||'Error').slice(0,64),name:san(name).slice(0,1024),stack:san(stack||'').slice(0,8192),path:san(location.pathname).slice(0,1024),fields:fields(f)});}catch(e){}} |
| window.addEventListener('error',function(ev){var e=ev.error;err(e&&e.name||'Error',ev.message||(e&&e.message)||'error',e&&e.stack||'',{file:ev.filename||'',line:String(ev.lineno||'')});}); |
| window.addEventListener('unhandledrejection',function(ev){var r=ev.reason;var m=r&&r.message||(typeof r==='string'?r:'unhandled rejection');err(r&&r.name||'UnhandledRejection',m,r&&r.stack||'',{});}); |
| -window.carlos={__v:1, |
| -track:function(name,f){send(A,{name:String(name||'').slice(0,64),path:location.pathname,fields:fields(f)});}, |
| +window.carlos={__v:1,__san:san, |
| +track:function(name,f){try{send(A,{name:String(name||'').slice(0,64),path:san(location.pathname).slice(0,1024),fields:fields(f)});}catch(e){}}, |
| error:function(e,f){err(e&&e.name||'Error',e&&e.message||String(e),e&&e.stack||'',f);}}; |
| })(); |
| ` |
diff --git a/internal/edge/carlosjs_test.go b/internal/edge/carlosjs_test.go| index 5a13d58de..2809b8740 100644 |
| --- a/internal/edge/carlosjs_test.go |
| +++ b/internal/edge/carlosjs_test.go |
| @@ -1,6 +1,7 @@ |
| package edge |
| import ( |
| + "encoding/json" |
| "net/http/httptest" |
| "os" |
| "os/exec" |
| @@ -79,3 +80,97 @@ func TestCarlosJSNodeSyntax(t *testing.T) { |
| t.Fatalf("node --check: %v\n%s", err, out) |
| } |
| } |
| + |
| +// TestCarlosJSSanitizer runs san() (exposed as the test seam |
| +// window.carlos.__san) through a table of inputs under real node, so the |
| +// redaction rules are checked behaviourally rather than by source |
| +// inspection. It skips when node is not on PATH. |
| +func TestCarlosJSSanitizer(t *testing.T) { |
| + nodePath, err := exec.LookPath("node") |
| + if err != nil { |
| + t.Skip("node not on PATH") |
| + } |
| + |
| + // A 40-char string that matches san()'s email pattern in full: 28 |
| + // local-part chars + "@example.com" (12 chars). |
| + email40 := strings.Repeat("a", 28) + "@example.com" |
| + if len(email40) != 40 { |
| + t.Fatalf("test bug: email40 is %d chars, want 40", len(email40)) |
| + } |
| + // Filler for the truncation case must NOT be a valid email local-part |
| + // character (letters/digits/._%+- all qualify), or the greedy email |
| + // regex swallows the filler into the "email" match too. |
| + tail9000 := strings.Repeat("!", 9000-len(email40)) + email40 |
| + |
| + cases := []string{ |
| + "contact me at foo@example.com now", |
| + "Authorization: Bearer abc.def-ghi", |
| + "?token=abc123&x=1", |
| + "da39a3ee5e6b4b0d3255bfef95601890afd80709", // 40-char hex digest |
| + "aB3dE6fG9hJ2kL5mN8pQ1rS4tU7vW+xYzAbCdEfG", // 40-char mixed base64-ish run with '+', no '/' |
| + "at Object.render (https://cdn.example.com/static/js/mainChunkVendors12ab/bundleFileName/index.js:2:1)", // stack frame: unchanged |
| + "/some/Long/Path1/with/many/segments/here/ok", // ordinary path: unchanged |
| + tail9000, // 9000 chars; the last 40 are an email |
| + } |
| + casesJSON, err := json.Marshal(cases) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + script := "var window={addEventListener:function(){}};\n" + |
| + "var navigator={sendBeacon:function(){return true;}};\n" + |
| + "var location={pathname:'/test'};\n" + |
| + "var document={createElement:function(){return{};},head:{appendChild:function(){}}};\n" + |
| + carlosJS + "\n" + |
| + "var cases=" + string(casesJSON) + ";\n" + |
| + "process.stdout.write(JSON.stringify(cases.map(function(c){return window.carlos.__san(c);})));\n" |
| + |
| + dir := t.TempDir() |
| + file := dir + "/san-check.js" |
| + if err := os.WriteFile(file, []byte(script), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + out, err := exec.Command(nodePath, file).CombinedOutput() |
| + if err != nil { |
| + t.Fatalf("node: %v\n%s", err, out) |
| + } |
| + |
| + var got []string |
| + if err := json.Unmarshal(out, &got); err != nil { |
| + t.Fatalf("node output not JSON: %v\n%s", err, out) |
| + } |
| + if len(got) != len(cases) { |
| + t.Fatalf("got %d results, want %d", len(got), len(cases)) |
| + } |
| + |
| + want := []string{ |
| + "contact me at <email> now", |
| + "Authorization: Bearer <redacted>", |
| + "?token=<redacted>&x=1", |
| + "<hex>", |
| + "<hex>", |
| + cases[5], |
| + cases[6], |
| + } |
| + for i, w := range want { |
| + if got[i] != w { |
| + t.Errorf("case %d (%q): got %q, want %q", i, cases[i], got[i], w) |
| + } |
| + } |
| + |
| + // The 9000-char case: sanitised before truncation (never leaks a raw |
| + // secret straddling the 8192 cut), never over the 8192 cap, and never |
| + // leaves a bare '@' behind even when the redacted tag itself lands |
| + // beyond the final window and is truncated away with it. |
| + last := got[len(got)-1] |
| + if len(last) > 8192 { |
| + t.Errorf("9000-char case: result is %d bytes, want <= 8192", len(last)) |
| + } |
| + if !strings.Contains(last, "<email>") && strings.Contains(last, "@") { |
| + tail := last |
| + if len(tail) > 40 { |
| + tail = tail[len(tail)-40:] |
| + } |
| + t.Errorf("9000-char case: raw '@' survived sanitisation+truncation, tail %q", tail) |
| + } |
| +} |