diff --git a/docs/testing.md b/docs/testing.md index 47dc887..56753c1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -17,3 +17,9 @@ Build before running `npm test`; Playwright starts and stops the local preview a `npm run test:starter` rebuilds the independent example and runs its generic check. Build the default edition again before deploying the default site. Do not run builds for different editions concurrently in one checkout: they share generated output paths. Tests use fresh browser contexts. Screenshots and failure traces stay in ignored local directories. Automated accessibility checks cover detected rules, not every aspect of accessibility; manually review reading comfort, keyboard order and a phone-sized view when changing layout or motion. + +## Search-fragment security regression — 5 October 2026 + +`tests/search-fragment.spec.ts` covers malformed percent escapes on direct and history navigation, safe first-section fallback, and valid encoded Unicode search. Search terms are decoded once per render through a guarded helper. Existing chapter, passage and ordinary search links retain their behavior. + +Verification for this candidate: 11 compiler/unit tests and the production build pass; all 14 pre-existing browser cases pass, and all four new search-fragment cases pass with installed Chrome. The first new-test run exposed an ambiguous locator (three Original text buttons); the test now selects the existing mode switch. npm's dated install audit reports zero vulnerabilities. This is local candidate evidence, not deployment or cloud-finding closure. diff --git a/src/main.tsx b/src/main.tsx index a0f10dd..7111740 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -22,6 +22,13 @@ function route() {try{return decodeURI(location.pathname).replace(/\/$/,'')||'/' function label(e:Entry) {const work=workFor(e.work);return work.numbering==='none'?work.unit:`${work.unit} ${numberFor(e.work,e.number)}`;} function readingTop(){const bar=document.querySelector('.reader-toolbar');return bar?Math.max(0,bar.getBoundingClientRect().bottom)+16:90;} +// URL fragments are external input, including when restored from history. +function searchFragment(sectionId:string):string|null { + if(!sectionId.startsWith('find-'))return null; + try{return decodeURIComponent(sectionId.slice(5)).toLowerCase() || null;} + catch{return null;} +} + function App(){ const [path,setPath]=useState(route); @@ -133,7 +140,8 @@ function App(){ setPreferences(p=>({...p,...patch})); if(anchor&&offset!==undefined)requestAnimationFrame(()=>{document.fonts.ready.then(()=>requestAnimationFrame(()=>window.scrollBy({top:anchor.getBoundingClientRect().top-offset,behavior:'instant'})));}); } - const selectedSection=chapter?.sections.find(s=>s.id===sectionId)||chapter?.sections.find(s=>sectionId.startsWith('find-')&&s.text.toLowerCase().includes(decodeURIComponent(sectionId.slice(5)).toLowerCase()))||chapter?.sections.find(s=>{ + const searchTerm=searchFragment(sectionId); + const selectedSection=chapter?.sections.find(s=>s.id===sectionId)||chapter?.sections.find(s=>searchTerm!==null&&s.text.toLowerCase().includes(searchTerm))||chapter?.sections.find(s=>{ const match=sectionId.match(/-q(\d+)$/);if(!match)return false; const before=chapter.sections.slice(0,chapter.sections.indexOf(s)).reduce((n,x)=>n+(x.html.match(/
/g)?.length??0),0); const count=s.html.match(/
/g)?.length??0; diff --git a/tests/search-fragment.spec.ts b/tests/search-fragment.spec.ts new file mode 100644 index 0000000..bdc52a9 --- /dev/null +++ b/tests/search-fragment.spec.ts @@ -0,0 +1,30 @@ +import { test, expect } from '@playwright/test'; + +for (const hash of ['find-%', 'find-%GG', 'find-%E0%A4%A']) { + test(`malformed fragment ${hash} falls back on direct and history navigation`, async ({page, request}) => { + const library = await (await request.get('/content/library.json')).json(); + const chapter = await (await request.get(`/content/${library[0].id}.json`)).json(); + const errors:string[]=[]; + page.on('pageerror', error => errors.push(error.message)); + await page.goto(`/read/${chapter.id}#${hash}`); + await expect(page.locator('#chapter-title')).toBeVisible(); + await expect(page.locator('.prose')).toContainText(chapter.sections[0].text.slice(0,40)); + await page.locator('.mode-switch').getByRole('button',{name:'Original text',exact:true}).click(); + await page.goBack(); + await expect(page.locator('.prose')).toContainText(chapter.sections[0].text.slice(0,40)); + await page.goForward(); + await expect(page.locator('#chapter-title')).toBeVisible(); + expect(errors).toEqual([]); + }); +} + +test('valid encoded Unicode search still selects the matching section', async ({page, request}) => { + const library = await (await request.get('/content/library.json')).json(); + const chapter = await (await request.get(`/content/${library[0].id}.json`)).json(); + // Use a synthetic section so the UTF-8 regression does not depend on edition prose. + chapter.sections[1].text = 'A café beside the sea'; + chapter.sections[1].html = '

A café beside the sea

'; + await page.route(`**/content/${chapter.id}.json`, route => route.fulfill({json:chapter})); + await page.goto(`/read/${chapter.id}#find-${encodeURIComponent('café')}`); + await expect(page.locator('.prose')).toContainText('A café beside the sea'); +});