diff --git a/app/globals.css b/app/globals.css index 92dae59..41784fa 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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}} diff --git a/deploy/README.md b/deploy/README.md index 644b95c..41582df 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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. Другие маршруты и сервисы не меняются. diff --git a/deploy/chromium-seccomp.json b/deploy/chromium-seccomp.json index fddc05f..1fa1f1a 100644 --- a/deploy/chromium-seccomp.json +++ b/deploy/chromium-seccomp.json @@ -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": [], diff --git a/lib/access.ts b/lib/access.ts index ef706cd..97d816d 100644 --- a/lib/access.ts +++ b/lib/access.ts @@ -17,7 +17,7 @@ function removeHidden(document:ReturnType['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')}); diff --git a/lib/browser.ts b/lib/browser.ts index 222a777..d34846d 100644 --- a/lib/browser.ts +++ b/lib/browser.ts @@ -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>|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>(); + const slots:Promise[]=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={};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={};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={}; 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:''});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)}; } } diff --git a/tests/browser.security.ts b/tests/browser.security.ts index 5de0dbc..4857e70 100644 --- a/tests/browser.security.ts +++ b/tests/browser.security.ts @@ -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[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})=>{ 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='

Book

Only the catalogue card has loaded.

'; - 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='

Book

Loading
'; + 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='

Book

Для доступа к чтению книги необходимо авторизоваться.'; + 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='

Book

Ознакомиться'; + 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='

Book

LockОзнакомиться'; + 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('','Читать полностью'); + assert.equal((await checkBrowser('https://book.ru/book/940586')).status,'unknown','A full-reading control contradicts the locked-preview-only signal'); });