fix: verify rendered library restrictions in sandboxed Chromium
This commit is contained in:
1 parent
6d5b920287
commit
b14a541344
6 files changed
+73
-16
No files matched your search
@@ -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
@@ -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. Другие маршруты и сервисы не меняются.
|
||||
@@ -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
@@ -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
@@ -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)};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user