fix: verify rendered library restrictions in sandboxed Chromium

This commit is contained in:
MIEM DEV committed 2026-10-30 19:26:19 +03:00
1 parent 6d5b920287
commit b14a541344
6 files changed
+73 -16

No files matched your search

+2
View File
@@ -8,3 +8,5 @@
.schedule{margin-top:16px;border-bottom:1px solid var(--line)}.schedule summary{display:flex;align-items:center;gap:12px;cursor:pointer;list-style:none;padding:17px 2px;color:var(--forest)}.schedule summary::-webkit-details-marker{display:none}.schedule__title{font-size:13px;font-weight:500}.schedule__title small{display:block;font-size:10px;font-weight:400;color:var(--muted);margin-top:3px}.schedule__state{margin-left:auto;font-size:10px;color:var(--muted);white-space:nowrap}.schedule__state--on{color:#42602f}.schedule__chevron{transition:transform .15s}.schedule details[open] .schedule__chevron{transform:rotate(180deg)}.schedule__form{padding:8px 0 24px}.schedule__toggle{display:flex;align-items:center;gap:9px;font-size:12px;font-weight:500;cursor:pointer}.schedule input[type=checkbox]{accent-color:var(--forest);width:15px;height:15px;margin:0}.schedule__fields{display:flex;flex-wrap:wrap;gap:16px;margin:20px 0 14px}.schedule__fields label{display:flex;flex-direction:column;gap:7px;font-size:10px;color:var(--muted);min-width:140px;flex:1}.schedule__fields select,.schedule__fields input{border:1px solid #c9d2c3;border-radius:4px;padding:11px 10px;background:var(--white);color:var(--ink);font-size:12px;min-width:0;width:100%;height:42px}.schedule__hint{font-size:11px;line-height:1.8;color:var(--muted);max-width:80ch;margin:10px 0}.schedule__history{font-size:11px;margin:12px 0;color:#52654b}.schedule__error{font-size:11px;line-height:1.7;color:var(--red);margin:12px 0;overflow-wrap:anywhere}.schedule__actions{display:flex;flex-wrap:wrap;gap:16px;align-items:center;margin-top:19px}.schedule__actions>span{display:flex;align-items:center;gap:7px;color:#52654b;font-size:11px}@media(max-width:760px){.schedule summary{gap:9px}.schedule__title{font-size:12px}.schedule__title small{font-size:10px;max-width:230px}.schedule__state{font-size:9px}.schedule__fields{gap:12px}.schedule__fields label{flex-basis:calc(50% - 12px);min-width:120px}.schedule__actions{gap:12px}.schedule__actions .button{width:100%}}
.link-row__reason{font-size:10px;line-height:1.6;margin:6px 0 0;color:var(--ink);overflow-wrap:anywhere}.reason__evidence{margin-top:12px}.reason__evidence blockquote{margin:0;padding:10px 12px;border:1px solid var(--line);border-radius:4px;background:var(--paper);font-size:11px;line-height:1.7}
@media(max-width:760px){.link-row__result{grid-column:1/-1;flex-wrap:wrap}.link-row__reason{flex-basis:100%;order:3;margin-top:0}.link-row__date{grid-column:1/-1;padding-left:19px}}
+2 -2
View File
@@ -1,8 +1,8 @@
# Chromium sandbox
chromium-seccomp.json — неизменённый профиль Microsoft Playwright v1.64.1:
chromium-seccomp.json — адаптация профиля Microsoft Playwright v1.64.1:
https://github.com/microsoft/playwright/blob/v1.64.1/utils/docker/seccomp_profile.json
Лицензия Apache-2.0 сохранена в PLAYWRIGHT-LICENSE. Профиль разрешает clone/setns/unshare для Chromium user namespaces и используется с chromiumSandbox:true, UID1000, no-new-privileges и cap-drop ALL. Не заменять seccomp на unconfined или Chromium sandbox на --no-sandbox. Read-only rootfs и временные /tmp,/dev/shm сохраняются.
Лицензия Apache-2.0 сохранена в PLAYWRIGHT-LICENSE. Профиль разрешает clone/setns/unshare/chroot для Chromium user namespaces и используется с chromiumSandbox:true, UID1000, no-new-privileges и cap-drop ALL. Не заменять seccomp на unconfined или Chromium sandbox на --no-sandbox. Дополнительно разрешён syscall chroot: он нужен Chromium внутри собственного user namespace; host/container capability SYS_CHROOT не добавляется, kernel permission checks сохраняются. Read-only rootfs и временные /tmp,/dev/shm сохраняются.
Nginx-сниппет повышает read timeout только для /courses до85s, чтобы browser deadline60s и сохранение данных завершались до90s lease. Другие маршруты и сервисы не меняются.
+3 -2
View File
@@ -51,11 +51,12 @@
],
"syscalls": [
{
"comment": "Allow create user namespaces",
"comment": "Allow Chromium user namespaces and chroot inside its namespace",
"names": [
"clone",
"setns",
"unshare"
"unshare",
"chroot"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
+1 -1
View File
@@ -17,7 +17,7 @@ function removeHidden(document:ReturnType<typeof parseHTML>['document']){
}
export function accessMessage(url:string,text:string):Check|null{
const normalized=text.replace(/\s+/g,' ').trim();
const preview=/доступн[а-я]*\s+(?:только\s+|первые\s+)?(\d{1,4})\s+страниц/i.exec(normalized);
const preview=/гостев[а-я]*\s+режим[а-я]*[^.]{0,180}?(\d{1,4})\s*страниц/i.exec(normalized)??/доступн[а-я]*\s+(?:только\s+|первые\s+)?(\d{1,4})\s+страниц/i.exec(normalized);
if(preview&&/гостев|ознакомительн|фрагмент/i.test(normalized))return result(url,'restricted',`Доступен только ознакомительный фрагмент — до ${preview[1]} страниц; полный текст недоступен.`,{evidence:evidence(url,normalized.slice(Math.max(0,preview.index-150),preview.index+200),'reader-preview-pages')});
if(/публикация\s+изъята\s+из\s+фонда/i.test(normalized))return result(url,'broken','Публикация изъята из фонда.',{evidence:evidence(url,normalized,'publication-withdrawn')});
if(/\/bookshelf\/\d+\/reading/.test(new URL(url).pathname)&&/для продолжения работы с сайтом.{0,60}войдите в систему/i.test(normalized))return result(url,'auth','Для чтения книги требуется авторизация.',{evidence:evidence(url,normalized,'ibooks-reader-login')});
+37 -9
View File
@@ -14,22 +14,23 @@ export async function checkBrowser(url:string,parentSignal?:AbortSignal):Promise
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),30000);
const abort=()=>controller.abort();parentSignal?.addEventListener('abort',abort,{once:true});if(parentSignal?.aborted)controller.abort();
let browser:Awaited<ReturnType<typeof chromium.launch>>|undefined;
let requests=0,totalBytes=0,failed=false,navigations=0,mainCode=200,limit:Check|null=null;
let requests=0,totalBytes=0,failed=false,navigations=0,mainCode=200,redirectTarget:string|null=null,limit:Check|null=null;
const active=new Set<Promise<void>>();
const slots:Promise<void>[]=Array.from({length:10},()=>Promise.resolve());
try{
controller.signal.throwIfAborted();browser=await chromium.launch({headless:true,chromiumSandbox:true,timeout:10000,env:{PATH:process.env.PATH??'/usr/bin:/bin',HOME:'/tmp',TMPDIR:'/tmp',LANG:'C.UTF-8'},args:[
'--proxy-server=http://127.0.0.1:9','--proxy-bypass-list=<-loopback>','--disable-quic','--force-webrtc-ip-handling-policy=disable_non_proxied_udp',
]});
controller.signal.throwIfAborted();const context=await browser.newContext({serviceWorkers:'block',acceptDownloads:false,viewport:{width:1280,height:900}});
controller.signal.throwIfAborted();const context=await browser.newContext({serviceWorkers:'block',acceptDownloads:false,javaScriptEnabled:!['znanium.ru','ibooks.ru','springer.com'].includes(root),viewport:{width:1280,height:900}});
controller.signal.addEventListener('abort',()=>{void context.close().catch(()=>{});},{once:true});
await context.routeWebSocket('**/*',socket=>socket.close());
const handle=async(route:Route)=>{
const request=route.request(),target=request.url(),navigation=request.isNavigationRequest();
try{
if(controller.signal.aborted||limit||!browserRequestAllowed(target,root,request.method(),request.resourceType())){await route.abort();return;}
if(++requests>100||totalBytes>32000000||active.size>=10)throw new Error('Превышен лимит ресурсов страницы');
if(totalBytes>32000000)throw new Error('Превышен лимит ресурсов страницы');
if(navigation){if(++navigations>8)throw new Error('Слишком много переходов');await waitForHost(new URL(target).hostname,controller.signal);}
const allHeaders=await request.allHeaders(),headers:Record<string,string>={};for(const key of ['accept','accept-language','origin','referer','cookie','content-type']){const value=allHeaders[key];if(value)headers[key]=value;}
const allHeaders=await request.allHeaders(),headers:Record<string,string>={};for(const key of ['accept','accept-language','origin','referer','cookie','content-type','user-agent']){const value=allHeaders[key];if(value)headers[key]=value;}
const response=await requestPublic(target,{signal:controller.signal,maxBytes:8000000,method:request.method() as 'GET'|'HEAD'|'OPTIONS',headers});
totalBytes+=response.body.length;if(totalBytes>32000000)throw new Error('Превышен лимит ресурсов страницы');
if([429,503].includes(response.code)){
@@ -39,6 +40,13 @@ export async function checkBrowser(url:string,parentSignal?:AbortSignal):Promise
if(navigation&&request.frame()===page.mainFrame())mainCode=response.code;
const fulfilled:Record<string,string>={};
for(const [key,value] of Object.entries(response.headers))if(value!==undefined&&!['content-encoding','content-length','transfer-encoding','connection'].includes(key))fulfilled[key]=Array.isArray(value)?value.join('\n'):String(value);
if([301,302,303,307,308].includes(response.code)&&response.headers.location){
if(!navigation||request.frame()!==page.mainFrame())throw new Error('Перенаправление ресурса не подтверждено');
const next=safeUrl(response.headers.location,target);if(!next||!browserRequestAllowed(next,root,'GET','document'))throw new Error('Перенаправление не разрешено');
redirectTarget=next;delete fulfilled.location;delete fulfilled['content-type'];
// Chromium routing may skip native redirect hops. Start each hop explicitly through the pinned boundary.
await route.fulfill({status:200,headers:fulfilled,contentType:'text/html',body:'<!doctype html><html><body></body></html>'});return;
}
await route.fulfill({status:response.code,headers:fulfilled,body:response.body});
}catch(error){
if(error instanceof HostCooldown)limit=result(url,'blocked',error.message);
@@ -47,10 +55,16 @@ export async function checkBrowser(url:string,parentSignal?:AbortSignal):Promise
}
};
// Every request is fulfilled by the pinned Node transport. The dead proxy also prevents direct browser egress.
await context.route('**/*',route=>{const task=handle(route);active.add(task);void task.finally(()=>active.delete(task));return task;});
await context.route('**/*',route=>{
const request=route.request();
if(controller.signal.aborted||limit||!browserRequestAllowed(request.url(),root,request.method(),request.resourceType()))return route.abort().catch(()=>{});
if(++requests>100){failed=true;return route.abort().catch(()=>{});}
const index=(requests-1)%slots.length,task=slots[index].then(()=>handle(route));slots[index]=task.catch(()=>{});active.add(task);void task.finally(()=>active.delete(task));return task;
});
const page=await context.newPage();
context.on('page',other=>{if(other!==page)void other.close().catch(()=>{});});
page.setDefaultTimeout(5000);page.setDefaultNavigationTimeout(20000);
const navigate=async(raw:string)=>{let next:string=raw;for(let hop=0;hop<5;hop++){redirectTarget=null;await page.goto(next,{waitUntil:'domcontentloaded'});if(!redirectTarget)return;next=redirectTarget;}throw new Error('Слишком много перенаправлений');};
const capture=async()=>{
await page.locator('body').waitFor({state:'attached'});
// Only rendered text is evidence; hidden login templates and site chrome cannot decide book access.
@@ -72,21 +86,35 @@ export async function checkBrowser(url:string,parentSignal?:AbortSignal):Promise
return /чтение книги недоступно|необходимо авторизоваться|публикация изъята|чтобы получить полный доступ|книга недоступна для чтения|для продолжения работы с сайтом|библиографическое описание|not found|captcha|проверка браузера/i.test(text)||!!document.querySelector('a[href*="/course-viewer/"],a[href*="/viewer/"],a[href*="reader.new.book.ru"],.book-offers__item,.app-article-access');
},undefined,{timeout:12000}).catch(()=>{});
};
await page.goto(url,{waitUntil:'domcontentloaded'});await settle();let snapshot=await capture();
await navigate(url);await settle();if(['urait.ru','book.ru'].includes(root))await page.waitForLoadState('networkidle',{timeout:4000}).catch(()=>{});let snapshot=await capture();
if(root==='book.ru'){
const preview=page.getByRole('link',{name:'Ознакомиться',exact:true});
// CSS tooltips become part of the accessible name on hover; retain a stable DOM-text locator.
const preview=page.locator('a[href*="reader.new.book.ru"]').filter({hasText:'Ознакомиться'}).first();
if(await preview.count()){
await preview.hover({timeout:3000}).catch(()=>{});
await page.waitForFunction(()=>/гостевом\s+режиме|доступны\s+только\s+\d+\s+страниц/i.test(document.body?.innerText??''),undefined,{timeout:3000}).catch(()=>{});snapshot=await capture();
const tooltip=await preview.evaluate(el=>[el,el.parentElement].filter((node):node is HTMLElement=>!!node).flatMap(node=>['::before','::after'].map(pseudo=>{const style=getComputedStyle(node,pseudo);return style.display!=='none'&&style.visibility!=='hidden'&&style.opacity!=='0'&&!['none','normal'].includes(style.content)?style.content.replace(/^["']|["']$/g,''):'';})).join(' ')).catch(()=>'');
snapshot.text+=' '+tooltip;
const message=accessMessage(snapshot.url,snapshot.text);if(message)return {...message,url,code:mainCode,finalUrl:evidenceUrl(snapshot.url)};
// BOOK.ru distinguishes its locked orange preview action from a full reading action.
const lockedPreview=await preview.evaluate(el=>el.className.includes('book_mode_orange__')&&!![...el.querySelectorAll('[class*="book_marker2__"]')].find(node=>{const style=getComputedStyle(node);return style.display!=='none'&&style.visibility!=='hidden'&&node.getClientRects().length>0;}));
const fullRead=await page.locator('a[href*="reader.new.book.ru"]').filter({hasText:/^\s*Читать(?: полностью)?\s*$/i}).count();
if(lockedPreview&&!fullRead)return result(url,'restricted','Доступен только ознакомительный режим; полный текст книги недоступен.',{code:mainCode,finalUrl:evidenceUrl(snapshot.url),evidence:{url:evidenceUrl(snapshot.url),text:'Ознакомиться',rule:'bookru-locked-preview'}});
}
}
const known=accessMessage(snapshot.url,snapshot.text)??purchaseOffer(snapshot.url,snapshot.offers);
if(known)return {...known,url,code:mainCode,finalUrl:evidenceUrl(snapshot.url)};
if(root==='urait.ru'||root==='ibooks.ru'){
// Follow only a book reader link already present on this exact card, never subscription/purchase controls.
const href=root==='urait.ru'?await page.locator('a[href*="/course-viewer/"],a[href*="/viewer/"]').first().getAttribute('href').catch(()=>null):snapshot.text.match(/https:\/\/ibooks\.ru\/bookshelf\/\d+\/reading/)?.[0]??null;
const modern=root==='urait.ru'?await page.locator('a[href*="/course-viewer/"]').first().getAttribute('href').catch(()=>null):null;
const href=root==='urait.ru'?modern??await page.locator('a[href*="/viewer/"]').first().getAttribute('href').catch(()=>null):snapshot.text.match(/https:\/\/ibooks\.ru\/bookshelf\/\d+\/reading/)?.[0]??null;
const reader=href?safeUrl(href,snapshot.url):null;
if(reader&&provider(reader)===root){await page.goto(reader,{waitUntil:'domcontentloaded'});await settle();snapshot=await capture();
if(reader&&provider(reader)===root){await navigate(reader);await settle();snapshot=await capture();
if(root==='urait.ru'&&new URL(snapshot.url).pathname.startsWith('/viewer/')){
const nextHref=await page.locator('a[href*="/course-viewer/"]').first().getAttribute('href').catch(()=>null),next=nextHref?safeUrl(nextHref,snapshot.url):null;
if(next&&provider(next)===root){await navigate(next);await settle();snapshot=await capture();}
}
const message=accessMessage(snapshot.url,snapshot.text);if(message)return {...message,url,code:mainCode,finalUrl:evidenceUrl(snapshot.url)};
}
}
+28 -2
View File
@@ -2,15 +2,22 @@ import assert from 'node:assert/strict';
import {test,mock} from 'node:test';
import {createServer} from 'node:http';
import {createSocket} from 'node:dgram';
import {chromium} from 'playwright';
import {useTestDatabase} from './test-database.ts';
useTestDatabase('browser');
const launch=chromium.launch.bind(chromium);
mock.method(chromium,'launch',async(options:Parameters<typeof chromium.launch>[0])=>{
try{return await launch(options);}catch(error){console.error('Chromium launch diagnostic:',(error as Error).message);throw error;}
});
const original=await import('../lib/http.ts');
let fixture='',proxied:string[]=[],cookieSeen=false;
mock.module(new URL('../lib/http.ts',import.meta.url).href,{namedExports:{...original,requestPublic:async(url:string,options:{headers?:Record<string,string>})=>{
proxied.push(url);
if(url.endsWith('/go-book'))return {code:302,headers:{location:'/book/111431'},body:Buffer.alloc(0),type:'text/html'};
if(url.endsWith('/session')){cookieSeen=options.headers?.cookie?.includes('anonymous_session=fixture')??false;return {code:200,headers:{'content-type':'text/plain'},body:Buffer.from('ok'),type:'text/plain'};}
if(url.includes('/probe/'))return {code:200,headers:{'content-type':'application/javascript'},body:Buffer.from('window.loadedChunks=(window.loadedChunks||0)+1;'),type:'application/javascript'};
if(url.endsWith('/redirect'))return {code:302,headers:{location:target},body:Buffer.alloc(0),type:'text/html'};
if(!url.startsWith('https://e.lanbook.com/'))throw new Error('Unexpected transport destination');
if(!/^https:\/\/(e\.lanbook\.com|book\.ru)\//.test(url))throw new Error('Unexpected transport destination');
return {code:200,headers:{'content-type':'text/html; charset=utf-8','set-cookie':['anonymous_session=fixture; Path=/; Secure; SameSite=Lax']},body:Buffer.from(fixture),type:'text/html'};
}}});
const {checkBrowser}=await import('../lib/browser.ts');
@@ -40,5 +47,24 @@ test('sandboxed Chromium blocks private HTTP, redirects, frames, popups, workers
});
test('a CSS-hidden login template does not make a known library book unavailable',async()=>{
fixture='<html><head><style>.reader-login-template {display:none}</style></head><body><h1>Book</h1><div class="reader-login-template">Для доступа к чтению книги необходимо авторизоваться.</div><p>Only the catalogue card has loaded.</p></body></html>';
const check=await checkBrowser('https://e.lanbook.com/book/2');assert.equal(check.status,'unknown',check.reason);
const check=await checkBrowser('https://e.lanbook.com/book/2');assert.equal(check.status,'unknown',check.reason);assert.equal(check.code,200,'The browser must actually render the fixture');
});
test('a burst of essential chunks is queued instead of dropping requests over the parallelism cap',async()=>{
fixture='<html><body><h1>Book</h1><div id="message">Loading</div><script>for(let i=0;i<16;i++){let s=document.createElement("script");s.src="/probe/"+i+".js";document.head.appendChild(s)}setInterval(()=>{if(window.loadedChunks===16)document.getElementById("message").textContent="Для доступа к чтению книги необходимо авторизоваться."},50)</script></body></html>';
const check=await checkBrowser('https://e.lanbook.com/book/3');assert.equal(check.status,'auth',check.reason);
});
test('a public document redirect is explicitly revalidated and rendered at its final URL',async()=>{
fixture='<html><body><h1>Book</h1>Для доступа к чтению книги необходимо авторизоваться.</body></html>';
const check=await checkBrowser('https://e.lanbook.com/go-book');assert.equal(check.status,'auth',check.reason);assert.equal(check.finalUrl,'https://e.lanbook.com/book/111431');
});
test('BOOK.ru CSS pseudo-element tooltip proves that only a guest fragment is available',async()=>{
fixture='<html><head><style>a::after{content:none}a:hover::after{content:"В гостевом режиме доступны только 20 страниц книги.";display:block}</style></head><body><h1>Book</h1><a href="https://reader.new.book.ru/">Ознакомиться</a></body></html>';
const check=await checkBrowser('https://book.ru/book/940586');assert.equal(check.status,'restricted',check.reason);assert.match(check.reason,/до 20 страниц/);
});
test('BOOK.ru locked orange preview is unavailable even without a loaded tooltip',async()=>{
fixture='<html><body><h1>Book</h1><a class="book_mode_orange__fixture" href="https://reader.new.book.ru/"><span class="book_marker2__fixture">Lock</span>Ознакомиться</a></body></html>';
const check=await checkBrowser('https://book.ru/book/940586');assert.equal(check.status,'restricted',check.reason);assert.equal(check.evidence?.rule,'bookru-locked-preview');assert.doesNotMatch(check.reason,/20/);
fixture=fixture.replace('</body>','<a href="https://reader.new.book.ru/">Читать полностью</a></body>');
assert.equal((await checkBrowser('https://book.ru/book/940586')).status,'unknown','A full-reading control contradicts the locked-preview-only signal');
});