carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev 128bf6ac1a1b56e24e8879d1451c66fe7c987405 parent 357920d
2 files changed, +104 −9
  • internal/edge/carlosjs.go +9 −9
  • internal/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)
+ }
+}