Skip to content

fix: индекси за листовите сортове + екраниране на JSON-LD (перформанс + hardening)#212

Open
B353N wants to merge 3 commits into
midt-bg:mainfrom
B353N:fix/list-hardening
Open

fix: индекси за листовите сортове + екраниране на JSON-LD (перформанс + hardening)#212
B353N wants to merge 3 commits into
midt-bg:mainfrom
B353N:fix/list-hardening

Conversation

@B353N

@B353N B353N commented Jul 4, 2026

Copy link
Copy Markdown

Консолидира двете доказуеми находки от прегледите (перформанс + сигурност) в един бранч. Заменя #211.

1. perf(db): индекси за подредбата на неполагащите листови сортове

Листовете странират с keyset ORDER BY <израз> <посока>, <id> <посока> LIMIT N. Дефолтните сортове имат съвпадащ индекс; шест избираеми сорта нямаха - планировчикът правеше SCAN <table> + USE TEMP B-TREE FOR ORDER BY, т.е. сканираше и сортираше цялата таблица преди LIMIT на всяка страница (D1 таксува сканираните редове).

Страница Сорт без индекс Причина
/contracts date-desc, date-asc idx_contracts_signed е на голото signed_at, а заявката подрежда по COALESCE(signed_at, …)
/companies count, authorities няма индекс на тези колони
/authorities count, avg няма индекс на тези колони

Миграция 0002_list_sort_indexes.sql добавя по един индекс, съвпадащ с точния ORDER BY израз + keyset id tiebreak. Адитивна, идемпотентна; rollup-ите се опресняват с DELETE+INSERT, затова индексите преживяват ship. Измерено при 200k договора: date-desc пада от ~0.30s на <10ms.

Съзнателно изключени (доказано, че не си струват): id-tiebreak на дефолтните индекси - бенчмаркът показа 0 измерима полза (single-col вече е ~0ms дори при много еднакви стойности), само излишен write-cost при refresh. ANALYZE - показано, че може да регресира /contracts value-desc до SCAN authorities.

2. fix(web): екраниране на < в JSON-LD data island-а (defense-in-depth)

root.tsx слага JSON-LD през dangerouslySetInnerHTML със суров JSON.stringify. JSON.stringify не екранира <, така че </script> в която и да е стойност би затворил <script> елемента рано (stored XSS) - точно sink-ът, който собственият ви стандарт (docs/review-security.md) изисква да е екраниран.

Не е експлоатируемо днес (единствената вкарана стойност е origin, а new URL() хвърля при host с </script>), затова е defense-in-depth: новият jsonLdScript helper затваря sink-а превантивно за всяко бъдещо поле от базата/потребителя в графа. Екранира << (JSON-еквивалентно) + U+2028/U+2029.

Доказателство (тестове)

  • packages/db/src/list-sort-indexes.test.ts - прилага миграциите на реален sqlite3 и за всеки от 6-те сорта проверява EXPLAIN QUERY PLAN: ПРЕДИ 0002 планът има USE TEMP B-TREE FOR ORDER BY (дефектът), СЛЕД - върви по новия индекс без sort step.
  • apps/web/app/lib/json-ld.test.ts - < се екранира (няма </script> breakout), U+2028/2029 се екранират, изходът остава JSON-еквивалентен.

Вид промяна

  • perf — индекси (без промяна в резултатите на заявките)
  • fix — security hardening (JSON-LD sink)

Как е тествано

  • pnpm typecheck - минава.
  • pnpm test - минава: @sigma/web 339 (вкл. 4 нови JSON-LD), @sigma/db 195 (вкл. 12 нови plan теста).
  • pnpm lint - чисто.

Чеклист

  • Conventional commits, без Co-Authored-By:
  • Форк → midt-bg/sigma:main
  • typecheck / test / lint минават
  • Миграцията е адитивна и идемпотентна (IF NOT EXISTS)
  • Няма тайни / .env* / .dev.vars

Бележки за координация

B353N added 2 commits July 4, 2026 12:17
The list pages keyset-paginate with ORDER BY <sortExpr> <dir>, <id> <dir> LIMIT N.
Six user-selectable sorts had no matching index, so the planner fell back to a
full table SCAN + temp-B-tree ORDER BY on every page (D1 bills rows scanned):

  /contracts   date-desc, date-asc   (idx_contracts_signed is on the bare column,
                                       not the COALESCE(signed_at, ...) expr the query uses)
  /companies   count, authorities
  /authorities count, avg

Add one index per missing sort, matching the exact ORDER BY expression plus the
keyset id tiebreak, so SQLite walks the index and stops at LIMIT. Additive,
idempotent; rollup tables are DELETE+INSERT-refreshed so the indexes survive ships.
A sqlite3 EXPLAIN QUERY PLAN test proves each sort full-scans before and index-walks
after.
root.tsx embeds JSON-LD via dangerouslySetInnerHTML with a raw JSON.stringify.
JSON.stringify does not escape '<', so a '</script>' in any string value would
close the <script> element early (stored XSS) — the exact sink the project's own
review standard (docs/review-security.md) requires be escaped. Today only the
request origin reaches the graph (new URL() cannot make it carry '</script>'), so
this is not currently exploitable; the jsonLdScript helper closes the sink
pre-emptively for any DB/user-derived field added later. A unit test proves '<' is
escaped, U+2028/U+2029 are escaped, and the output stays JSON-equivalent.
@ydimitrof

Copy link
Copy Markdown

Благодаря за прегледната и добре документирана заявка. Прочетох целия diff, PR описанието, двата коммита и локално сверих индексите спрямо реалния код на заявките, keyset строителя, съществуващите миграции и XSS sink-а. По-долу е резултатът.

Обхват

Два доказуеми hardening-a в един бранч (заменя #211):

  1. perf(db) — 6 ordering индекса за неполагащите листови сортове (0002_list_sort_indexes.sql).
  2. fix(web) — екраниране на < (+ U+2028/U+2029) в JSON-LD data island-а през новия jsonLdScript helper.

Сигурност и целостност на данните — чисто

SQL injection — няма. Сортовете минават през allowlist преди да достигнат SQL:

  • SORTS е lookup()-обект с null-прототип; normalize*Sort пропуска само стойност, за която value in SORTS, иначе пада на дефолта (value-desc/won/spent).
  • keyset.ts допълнително минава колоните през assertSafeColumn срещу allowedSortCols/allowedIdCols и assertSortDir преди интерполация. Никаква потребителска стойност не влиза сурова в ORDER BY.

Индексите съвпадат байт-по-байт с емитирания ORDER BY — сверено срещу изворния код, не само срещу PR текста:

  • contracts date-desc → COALESCE(c.signed_at, '') DESC, c.id DESCidx_contracts_signed_desc
  • contracts date-asc → COALESCE(c.signed_at, '9999-99') ASC, c.id ASCidx_contracts_signed_asc
  • company_totals count(contracts)/authorities DESC, bidder_id DESC ✔
  • authority_totals count(contracts)/avg_eur DESC, authority_id DESC ✔

keyset.ts:156 строи ORDER BY <col> <dir>, <idCol> <dir> с една и съща посока за двете колони, а tiebreak-ът на индекса е в същата посока — значи SQLite нито сортира, нито буферира; за before курсор посоката се инвертира и SQLite обхожда същия индекс наобратно. Тестовете list-sort-indexes.test.ts доказват това с EXPLAIN QUERY PLAN на реален sqlite3 без ANALYZE (както е в production D1): ПРЕДИ — USE TEMP B-TREE FOR ORDER BY, СЛЕД — index-walk без sort стъпка. Съзнателните изключения (id-tiebreak на дефолтите, ANALYZE) са аргументирани.

XSS sink — коректно затворен. jsonLdScript екранира <\u003c (достатъчно, за да не се формира </script>) плюс U+2028/2029; изходът остава JSON-еквивалентен (round-trip тестван). Sink-ът е <script type="application/ld+json" dangerouslySetInnerHTML> в root.tsx:128. Днес единствената вкарана стойност е new URL(request.url).origin, който не може да носи </script> — т.е. твърдението за defense-in-depth, а не текущ експлойт, е вярно. Съответства на docs/review-security.md и на safeJson от contract.json.tsx.

Проверих и за backdoor/обфускация/промяна на URL-и/нови зависимости — няма. Миграцията е адитивна и идемпотентна (IF NOT EXISTS), а rollup таблиците се опресняват с DELETE+INSERT, така че индексите преживяват ship.

Дребни бележки (не блокират)

  • list-sort-indexes.test.ts изисква наличен sqlite3 CLI в CI средата (execFileSync). Ако runner-ът го няма, тестът ще падне на средата, не на кода — струва си да се потвърди, че е наличен.
  • Планът се доказва само за нефилтрираната листа. С активни WHERE филтри планировчикът може да избере друг път — приемливо за дефолтната страница (най-честият случай), но е честно да се отбележи, че покритието не се простира върху филтрираните заявки.
  • Номерът 0002 се застъпва с този на feat(web): „Подобни договори" - ценови ориентир по CPV кохорта на страницата на договора #210 — вече отбелязано в описанието: който се мерджне втори, преномерира на 0003. Моля не забравяйте стъпката при мерджа.

Нищо от горното не е дефект в самата промяна.

Вердикт: Approve (одобрявам на същество) — сигурност и целостност на данните чисти, OWASP-съвместимо; преди мердж уредете само номерацията на миграцията спрямо #210 и потвърдете sqlite3 в CI.

@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Одобрявам (088476d): четирите covering индекса съвпадат точно с ORDER BY + keyset посоката на query слоя (idx_contracts_signed_desc (COALESCE(signed_at,'') DESC, id DESC)SORTS['date-desc'] + keyset({dir:'desc'}), ..._signed_ascdate-asc, company/authority count/avg също). list-sort-indexes.test.ts доказва през EXPLAIN QUERY PLAN, че преди миграцията се хваща USE TEMP B-TREE FOR ORDER BY, а след нея индексът се обхожда без sort — правилният метод за проверка. JSON-LD: << (затваря </script>/<!-- breakout) + U+2028/U+2029 — стандартното JSON-еквивалентно hardening; root.tsx подава само origin от new URL().

Бележка (cross-PR): този PR добавя 0002_list_sort_indexes.sql, а #172 (0002_contracts_overrun_index) и #210 (0002_cpv_division_stats) също claim-ват 0002. Който влезе пръв, останалите се преномерират на 0003+ преди merge (иначе два 0002 при merge).

@nedda76 nedda76 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Прегледах двете части — чисто.

A. JS екраниране (XSS). jsonLdScript е коректен фикс: екранира всяко < като < (обезврежда </script, <!-- и double-escape странностите) + U+2028/U+2029, и остава JSON-еквивалентно. Приложен е върху единствения inline-script sink в приложението (JSON-LD острова в root.tsx); всичко останало минава през auto-escape на React. Няма останал reflected/stored XSS път — включително през атакуемо DB поле (напр. фирма, регистрирана с <script в името).

B. Индекси. И шестте нови индекса точно съвпадат с ORDER BY <expr> <dir>, <id> <dir>, който keyset пейджърът издава (вкл. COALESCE(signed_at,'') изразите — затова не дублират заварения idx_contracts_signed); никой не дублира съществуващ индекс; migration-ът е нов, адитивен, идемпотентен (CREATE INDEX IF NOT EXISTS). Уговорка: индексите ускоряват само нефилтрирания списък — при активни филтри source() минава към derived GROUP BY, който не може да ги ползва. Присъщо на подхода; hot path-ът (списък по подразбиране) е покрит правилно.

Координация: #212 и #210 добавят по един 0002_* migration — който влезе втори, трябва да се преномерира на 0003, иначе един от двата тихо няма да се приложи.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants