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
+1 -1
View File
@@ -8,4 +8,4 @@ Streaming is relayed through [`us.proxy.sune.chat`](https://github.com/sune-org/
**What it doesn't do:** no prompt logging (your messages are held in memory only while the run is in progress; the generated reply stays in memory for 90 minutes after it ends so you can reconnect, and nothing is written to disk), no reading your chats, no storing keys, no third-party sharing.
**What I see:** an [ntfy](https://ntfy.sh) ping when a run ends/fails, containing only: run ID, `[provider/model]`, duration, error message (if any), and the sune's non-empty generation settings (temperature, top_p, top_k, frequency/repetition penalty, min_p, top_a, max tokens, reasoning effort, show thoughts, image output/aspect ratio/size). No prompts (system or master prompts included), no responses, no IP, no key. Source is public — audit it [here](https://github.com/sune-org/us.proxy.sune.chat).
**What I see:** an [ntfy](https://ntfy.sh) ping when a run ends/fails, containing only: the run's start time (the timestamp half of its ID, not the secret half), `[provider/model]`, duration, error message (if any), and the sune's non-empty generation settings (temperature, top_p, top_k, frequency/repetition penalty, min_p, top_a, max tokens, reasoning effort, show thoughts, image output/aspect ratio/size). No prompts (system or master prompts included), no responses, no IP, no key. Source is public — audit it [here](https://github.com/sune-org/us.proxy.sune.chat).
+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;