Make reply ids unguessable and keep them out of the socket URL

Reply ids, which double as the proxy's run id, are now the same base36 ms
timestamp followed by a dash and 128 random bits from crypto.getRandomValues,
so a run can't be found by guessing. Saved replies keep their fields and the
timestamp prefix. The id is sent inside begin, resume and stop instead of the
URL, and SECURITY.md now says ntfy pings carry only the timestamp half.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 03:56:12 +00:00
co-authored by Claude Opus 5.5
parent ba32afa631
commit bd91949698
3 changed files with 9 additions and 8 deletions
+6 -6
View File
@@ -53,7 +53,6 @@ export const buildBody=()=>{
you're elsewhere, reconnect on drops, and get picked back up after a reload. A streaming message carries
stream:{seq} (the last chunk it holds) until it's finished. */
const live=new Map();
const wsUrl=rid=>`${WS_URL}?rid=${encodeURIComponent(rid)}`;
const threadKey=tid=>(el.threadRepoInput.value.trim().startsWith('gh://')?'rem_t_':'t_')+tid;
const saveLive=()=>localStorage.setItem(LIVE_KEY,JSON.stringify([...live.values()].map(s=>[s.rid,s.tid,s.key])));
const onScreen=s=>window.state.currentThreadId===s.tid&&window.state.messages.includes(s.msg);
@@ -102,8 +101,9 @@ function finish(s,note=''){
// Stops the run server-side too; without an open socket a throwaway one carries the stop
function sendStop(s){
if(s.ws?.readyState===1)return s.ws.send('{"type":"stop"}');
const w=new WebSocket(wsUrl(s.rid));w.onopen=()=>{w.send('{"type":"stop"}');w.close()}
const m=JSON.stringify({type:'stop',rid:s.rid});
if(s.ws?.readyState===1)return s.ws.send(m);
const w=new WebSocket(WS_URL);w.onopen=()=>{w.send(m);w.close()}
}
// For a deleted message or thread: stop without saving anything
@@ -130,8 +130,8 @@ function onMessage(s,m){
function connect(s){
if(s.done)return;clearTimeout(s.timer);
const ws=s.ws=new WebSocket(wsUrl(s.rid));
ws.onopen=()=>ws.send(s.req||JSON.stringify({type:'resume',after:s.msg.stream.seq}));
const ws=s.ws=new WebSocket(WS_URL);
ws.onopen=()=>ws.send(s.req||JSON.stringify({type:'resume',rid:s.rid,after:s.msg.stream.seq}));
ws.onmessage=e=>{if(s.ws!==ws)return;let m;try{m=JSON.parse(e.data)}catch{return}onMessage(s,m)};
ws.onclose=()=>{
if(s.ws!==ws||s.done)return;s.ws=null;retry(s)
@@ -165,7 +165,7 @@ export function startReply(msg){
const apiKey=provider==='openai'?USER.apiKeyOpenAI:provider==='google'?USER.apiKeyGoogle:provider==='claude'?USER.apiKeyClaude:USER.apiKeyOpenRouter;
const s=track(msg,state.currentThreadId,threadKey(state.currentThreadId));
if(!apiKey){msg.content[0].text=window.localDemoReply();return finish(s)}
s.req=JSON.stringify({type:'begin',provider,apiKey,body:buildBody()});
s.req=JSON.stringify({type:'begin',rid:s.rid,provider,apiKey,body:buildBody()});
if(s.req.length>MAX_REQ-(1<<20))return finish(s,'\n\nThis request is over the server’s 100 MB limit.');
connect(s);syncBtn()
}
+2 -1
View File
@@ -8,7 +8,8 @@ export const positionPopover = (a, p) => {
p.style.top = `${r.bottom + p.offsetHeight + 4 > window.innerHeight ? r.top - p.offsetHeight - 4 : r.bottom + 4}px`;
p.style.left = `${Math.max(8, Math.min(r.right - p.offsetWidth, window.innerWidth - p.offsetWidth - 8))}px`;
};
export const sid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
// Reply ids double as the proxy's run id, so after the ms timestamp come 128 random bits
export const sid = () => Date.now().toString(36) + '-' + [...crypto.getRandomValues(new Uint8Array(16))].map(b => b.toString(16).padStart(2, '0')).join('');
export const fmtSize = b => {
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0, x = b;