33 lines
6.8 KiB
JavaScript
33 lines
6.8 KiB
JavaScript
'use strict';
|
|
const fs=require('node:fs'),path=require('node:path'),{randomUUID}=require('node:crypto');
|
|
const MAX_FILE=1048576, UUID=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
const fail=()=>{throw Error('Managed session collection unavailable');};
|
|
const secret=s=>/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----|\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,})\b|\b(?:password|passwd|api[_ -]?key|access[_ -]?token|client[_ -]?secret)\s*[:=]\s*["']?[^\s"']{4,}/i.test(s);
|
|
function safeText(value,max){if(typeof value!=='string'||value.length>max||/[\u0000-\u001f\u007f]/.test(value))fail();let decoded=value;for(let i=0;i<5;i++){if(secret(decoded))fail();try{const next=decodeURIComponent(decoded);if(next===decoded)break;decoded=next;}catch{break;}}if(secret(decoded))fail();return value;}
|
|
// Preserve benign query/fragment semantics; identifiable authentication material
|
|
// and authentication routes are never reusable collection destinations.
|
|
function unsafeURL(url){let decoded=url.href;for(let i=0;i<5;i++){if(/[?&#;](?:password|passwd|token|access[_-]?token|refresh[_-]?token|api[_-]?key|secret|client[_-]?secret|code|authorization|auth|credential|session|sid|samlresponse|samlrequest|assertion|ticket|id[_-]?token)=/i.test(decoded))return true;try{const next=decodeURIComponent(decoded);if(next===decoded)break;decoded=next;}catch{break;}}let pathname=url.pathname;for(let i=0;i<5;i++){try{const next=decodeURIComponent(pathname);if(next===pathname)break;pathname=next;}catch{break;}}return /\/(?:login|signin|sign-in|auth|oauth|sso|password|reset|callback)(?:\/|$)/i.test(pathname);}
|
|
function sanitizeSessionCollection(input){
|
|
if(!input||typeof input!=='object'||Array.isArray(input)||Object.keys(input).some(k=>!['id','name','tabs','activeIndex'].includes(k)))fail();
|
|
if(input.id!==undefined&&!UUID.test(input.id))fail();
|
|
const name=safeText(input.name,100);if(!name.trim()||!Array.isArray(input.tabs)||input.tabs.length<1||input.tabs.length>32||!Number.isInteger(input.activeIndex)||input.activeIndex<0||input.activeIndex>=input.tabs.length)fail();
|
|
const tabs=input.tabs.map(row=>{if(!row||typeof row!=='object'||Array.isArray(row)||Object.keys(row).some(k=>!['url','title','group'].includes(k)))fail();const value=safeText(row.url,4096);let url;try{url=new URL(value);}catch{fail();}if(!['http:','https:'].includes(url.protocol)||url.username||url.password||unsafeURL(url))fail();return {url:url.href,title:safeText(row.title,200),...(row.group===undefined?{}:{group:safeText(row.group,100)})};});
|
|
const result={...(input.id?{id:input.id}:{}),name,tabs,activeIndex:input.activeIndex};if(Buffer.byteLength(JSON.stringify(result))>196608)fail();return result;
|
|
}
|
|
function createSessionSnapshotStore({filePath,protect,unprotect}={}){
|
|
if(typeof filePath!=='string'||!path.isAbsolute(filePath)||typeof protect!=='function'||typeof unprotect!=='function')fail();
|
|
const binding=({deviceId,ownerId}={})=>{if(typeof deviceId!=='string'||!UUID.test(deviceId)||typeof ownerId!=='string'||ownerId.length<1||ownerId.length>512)fail();return {deviceId,ownerId};};
|
|
function read(){if(!fs.existsSync(filePath))return {format:1,revision:randomUUID(),collections:[]};const stat=fs.lstatSync(filePath);if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1||stat.size<1||stat.size>MAX_FILE)fail();let encrypted,plain,fd;try{fd=fs.openSync(filePath,fs.constants.O_RDONLY|(fs.constants.O_NOFOLLOW||0));const current=fs.fstatSync(fd);if(current.ino!==stat.ino||current.dev!==stat.dev||current.nlink!==1||current.size>MAX_FILE)fail();encrypted=fs.readFileSync(fd);if(encrypted.length>MAX_FILE)fail();plain=unprotect(encrypted);if(!Buffer.isBuffer(plain)||plain.length>MAX_FILE)fail();const data=JSON.parse(plain.toString('utf8'));if(data.format!==1||!UUID.test(data.revision)||!Array.isArray(data.collections)||data.collections.length>16)fail();const ids=new Set();data.collections=data.collections.map(row=>{binding(row);const collection=sanitizeSessionCollection(row.collection);if(!collection.id||ids.has(collection.id))fail();ids.add(collection.id);return {deviceId:row.deviceId,ownerId:row.ownerId,collection};});return data;}catch{fail();}finally{if(fd!==undefined)fs.closeSync(fd);plain?.fill(0);encrypted?.fill(0);}}
|
|
function write(data,options){let plain,encrypted,temp,fd;try{plain=Buffer.from(JSON.stringify(data));if(plain.length>MAX_FILE)fail();encrypted=protect(plain);if(!Buffer.isBuffer(encrypted)||encrypted.length<1||encrypted.length>MAX_FILE)fail();fs.mkdirSync(path.dirname(filePath),{recursive:true});if(fs.existsSync(filePath)){const stat=fs.lstatSync(filePath);if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1)fail();}temp=filePath+'.'+randomUUID()+'.tmp';fd=fs.openSync(temp,'wx',0o600);fs.writeFileSync(fd,encrypted);fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;if(state().revision!==options.expectedRevision)fail();const checked=options.assertCurrent?.();if(checked&&typeof checked.then==='function')fail();if(state().revision!==options.expectedRevision)fail();fs.renameSync(temp,filePath);temp=null;}finally{if(fd!==undefined)fs.closeSync(fd);if(temp)try{fs.unlinkSync(temp);}catch{}plain?.fill(0);encrypted?.fill(0);}}
|
|
let emptyRevision=randomUUID();
|
|
const state=()=>fs.existsSync(filePath)?read():{format:1,revision:emptyRevision,collections:[]};
|
|
const matches=(row,b)=>row.deviceId===b.deviceId&&row.ownerId===b.ownerId;
|
|
return {
|
|
list(options){const b=binding(options),data=state();return {revision:data.revision,collections:data.collections.filter(row=>matches(row,b)).map(row=>structuredClone(row.collection))};},
|
|
load(options){const b=binding(options);if(!UUID.test(options.id))fail();const data=state(),row=data.collections.find(row=>matches(row,b)&&row.collection.id===options.id);if(!row)fail();return {revision:data.revision,...structuredClone(row.collection)};},
|
|
save(input,options){const b=binding(options),data=state();if(options.expectedRevision!==data.revision)fail();const collection=sanitizeSessionCollection(input);const existing=collection.id&&data.collections.find(row=>row.collection.id===collection.id);if(collection.id&&(!existing||!matches(existing,b)))fail();if(!existing&&data.collections.length>=16)fail();collection.id??=randomUUID();if(existing)existing.collection=collection;else data.collections.push({...b,collection});data.revision=randomUUID();write(data,options);emptyRevision=data.revision;return {id:collection.id,revision:data.revision};},
|
|
remove(options){const b=binding(options),data=state();if(options.expectedRevision!==data.revision||!UUID.test(options.id))fail();const index=data.collections.findIndex(row=>matches(row,b)&&row.collection.id===options.id);if(index<0)fail();data.collections.splice(index,1);data.revision=randomUUID();write(data,options);return {revision:data.revision};}
|
|
};
|
|
}
|
|
module.exports={sanitizeSessionCollection,createSessionSnapshotStore};
|