Repository navigation
Description
Research note (finding-triage wave 45, harper origin/main ee7f9b6): Gap in #3824 (merged 2026-10-31) and #2855. Only security effect is a 403 versus 400 oracle; no data loss.
Summary
Following HarperFast/harper#2855 (eed63b790, 5155f1c59) and HarperFast/harper#3824 (6048a06709a7bff15b6d5732fb0b3ecdc69b8098, "Adopt fulltext 0.8 with host query policy and fuzzy controls"), three non-overlapping full-text validation and authorization-oracle defects remain in resources/Table.ts across rocksdb and lmdb:
-
Arm 1 — Negated full-text comparators (
not_matches,not_matches_all,not_matches_phrase,not_matches_prefix,not_matches_fuzzy,not_matches_fuzzy_prefix) bypass_prepareRelationshipSelection'srejectUnsupportedguard (resources/Table.ts:7893-7908):- In
Table._prepareRelationshipSelection(resources/Table.ts:7893-7900),rejectUnsupportedcheckselse if (fullTextComparatorMode(condition.comparator))at:7896beforetargetTable._prepareQueryConditions(:7901-7907) callsresolveComparator(condition.comparator)(:8200) to strip'not_'. - Because
fullTextComparatorMode(resources/indexes/fullTextQueryProtocol.ts:32-49) only recognizes the 6 positive comparators (matches,matches_all,matches_phrase,matches_prefix,matches_fuzzy,matches_fuzzy_prefix) and returnsundefinedfor all 6'not_matches*'comparators (unlikeresources/search.ts:2626, which callsfullTextComparatorMode(resolveComparator(comparator).comparator)), negated full-text predicates in relationshipselect.conditionsbypassrejectUnsupported:- Sub-case 1a: On
Owner.searchandOwner.getwithselect: ['id', { name: 'products', conditions: [{ attribute: 'catalogSearch', comparator: 'not_matches*', value: 'shoe' }], select: ['id'] }](both top-level and nested inside{ operator: 'and', conditions: [...] }),Product._prepareQueryConditionsstrips'not_'andcompile()(:7911) callsfilterByType(resources/search.ts:2073), failing with400 ClientError: Unknown query comparator "matches..."instead of400 ClientError: Full-text predicates are not supported in relationship selections. - Sub-case 1b: When a restricted user with
attribute_permissionsdenyingProduct.secretqueriesOwner(checkPermission: true) withconditions: [{ attribute: 'secretSearch', comparator: 'not_matches', value: 'classified' }], positive'matches'rejects at:7897with400 Full-text predicates are not supported in relationship selections, whereas'not_matches'executesProduct._prepareQueryConditions(:7901->assertFullTextReadAccessat:8229) and throws403 AccessViolation: Unauthorized access to resource— an authorization oracle leak revealing whethersecretSearchindexes a restricted source attribute onProduct.
- Sub-case 1a: On
- In
-
Arm 2 — Relationship-path
sort(sort: { attribute: ['product', 'catalogSearch'] }and['products', 'catalogSearch']) onTable.search(resources/Table.ts:7556-7561) and nested relationshipselect.sort(resources/Table.ts:2208-2211) bypassassertFullTextRecordField:- Both
Table.search(resources/Table.ts:7557:if (fullTextFieldNames) assertRecordField(order.attribute)) andTable.assertFullTextSelection(select, sort)(resources/Table.ts:2210:for (let order = sort; order; order = order.next) assertRecordField(order.attribute)) callassertRecordField(order.attribute)(:12027-12034), which only checksorder.attribute[0]against the current table'sfullTextFieldNames(and inTable.searchis skipped completely when the queried table hasfullTextFieldNames === undefined), instead ofassertFullTextRecordField(order.attribute)(:12036-12044), which traversespropertyResolvers[first]?.definition?.tableClass?.assertFullTextRecordField:- Sub-case 2a: On
Table.search(both onOwnerwithfullTextFieldNames === undefinedandAuthorWithFullTextwithfullTextFieldNames !== undefined), secondary sortsort: { attribute: 'id', next: { attribute: ['product', 'catalogSearch'] } }andoperator: 'or'primary sortsort: { attribute: ['product', 'catalogSearch'] }silently succeed and return all rows instead of throwing400 Full-text field "catalogSearch" is query-only, while unalignedANDprimary sort falls through tofindAttribute(:7575) and throws404 Error: product.catalogSearch is not a defined attributeinstead of400 Full-text field "catalogSearch" is query-only. - Sub-case 2b: On 2-hop nested relationship
select.sort(Store.searchandStore.getwithselect: ['id', { name: 'owners', select: ['id'], sort: { attribute: ['product', 'catalogSearch'] } }]),Owner.assertFullTextSelectionat:2210callsassertRecordFieldinstead ofassertFullTextRecordField, silently succeeding and returning all rows instead of throwing400 Full-text field "catalogSearch" is query-only.
- Sub-case 2a: On
- Both
-
Arm 3 —
_prepareQueryConditionson tables withfullTextIndexes.length === 0(resources/Table.ts:8208-8222) forcesfullTextMode = undefinedfor string attributes:- At
resources/Table.ts:8208-8213,fullTextModeis gated byTableResource.fullTextIndexes.length > 0 || Array.isArray(attribute_name) || (typeof attribute_name === 'string' && fullTextFieldNames?.has(attribute_name)). On any table without a@fullTextindex (PlainItem),fullTextModeis forced toundefinedfor string attributes:- Sub-case 3a:
{ attribute: 'title', comparator: 'matches_fuzzy' | 'matches_fuzzy_prefix' | 'not_matches_fuzzy', value: 'shoe', fuzzyDistance: 1 }(orfuzzyTranspositions: false) callsvalidateFullTextFuzzyOptions(condition, undefined)(:8219) and throws the self-contradictory400 ClientError: Fuzzy controls require matches_fuzzy or matches_fuzzy_prefix on a full-text index conditioninstead of400 ClientError: Full-text comparator requires a declared @fullText index. - Sub-case 3b: A non-leading full-text condition in an
ANDquery ([{ attribute: 'id', comparator: 'equals', value: 'item-1' }, { attribute: 'title', comparator: 'matches' | 'not_matches', value: 'shoe' }]) skipsthrowUnknownFullTextIndex(:8222), runs the index lookup, and fails insidefilterByType(resources/search.ts:2073) with400 ClientError: Unknown query comparator "matches"instead of400 ClientError: Full-text comparator requires a declared @fullText index. - Sub-case 3c: For a restricted
attribute_permissionsuser querying{ attribute: 'secret', comparator: 'matches', value: 'classified' }or{ attribute: 'unknownAttr', comparator: 'matches', value: 'x' }onPlainItem, skippingthrowUnknownFullTextIndex(:8222->:12098-12103) leaks400 Full-text comparator requires a declared @fullText index(on'secret') and404 unknownAttr is not a defined attribute(on'unknownAttr') instead of throwing403 AccessViolation.
- Sub-case 3a:
- At
Reproduction
On HarperFast/harper main at 6048a06709a7bff15b6d5732fb0b3ecdc69b8098 (with qa5101-fulltext-relationship-sort-and-table-guards.test.js placed under unitTests/resources/):
# Default defect mode (passes 16/16 on unpatched main across rocksdb and lmdb):
HARPER_STORAGE_ENGINE=rocksdb NODE_OPTIONS="--conditions=typestrip" npx mocha --exit unitTests/resources/qa5101-fulltext-relationship-sort-and-table-guards.test.js
HARPER_STORAGE_ENGINE=lmdb NODE_OPTIONS="--conditions=typestrip" npx mocha --exit unitTests/resources/qa5101-fulltext-relationship-sort-and-table-guards.test.js
# Contract mode (3 positive controls pass, 13 defect tests fail on unpatched main; 16/16 pass with fix):
QA_ASSERT_MODE=contract HARPER_STORAGE_ENGINE=rocksdb NODE_OPTIONS="--conditions=typestrip" npx mocha --exit unitTests/resources/qa5101-fulltext-relationship-sort-and-table-guards.test.jsCounterfactual Fix Verified in Gate 2 (resources/Table.ts)
- In
Table.assertFullTextSelection(resources/Table.ts:2210), changeassertRecordField(order.attribute)toassertFullTextRecordField(order.attribute). - In
Table.search(resources/Table.ts:7557), changeif (fullTextFieldNames) assertRecordField(order.attribute)toif (fullTextFieldNames || hasRelationships) assertFullTextRecordField(order.attribute). - In
Table._prepareRelationshipSelection(resources/Table.ts:7896), changeelse if (fullTextComparatorMode(condition.comparator))toelse if (fullTextComparatorMode(resolveComparator(condition.comparator).comparator)). - In
Table._prepareQueryConditions(resources/Table.ts:8208-8213), changeconst fullTextMode = ... ? fullTextComparatorMode(condition.comparator) : undefinedtoconst fullTextMode = fullTextComparatorMode(condition.comparator).
With this 4-site change in resources/Table.ts:
QA_ASSERT_MODE=contractpasses16 passing (15s)onrocksdband16 passing (1s)onlmdb.QA_ASSERT_MODE=defectfails all13defect tests (3 passing, 13 failing).- Existing full-text suites (
fullTextFields.test.js,fullTextSchema.test.js,fullTextSearchLimits.test.js) pass64 passing, 0 failing.
Existing Work & Scope
- Searched
HarperFast/harperandHarperFast/harper-proissues and PRs:harper#2855(merged),harper#3824(merged),harper#3795(open),harper#3775(merged),harper#3735(merged),harper#3447(open). None track these three defects. - Checked
resources/FULL_TEXT.md:70-86, 188-190on6048a06709a7bff15b6d5732fb0b3ecdc69b8098andHarperFast/documentationatfc17e9ddb92ad5a1ecc193e6017b680643a4a19b(reference/full-text-search/querying.md:33-47, 66-85). - What this does NOT prove: External HTTP REST / Operations API callers that invoke
Table.allowRead(resources/Table.ts:4300) beforeTable.searchon a table withfullTextIndexes.length === 0(PlainItem) were not tested end-to-end over HTTP here;Table.allowRead(:4300) also gatesFULL_TEXT_READ_PERMISSIONcaching onTableResource.fullTextIndexes.length > 0, whereasgetTablePermissions(:12018-12025) falls back touser?.role?.permissionwhenuseris present oncontext.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
kriszyp commentedon Nov 10, 2026
A third site with the arm-1 mechanism (QA finding F-1196, re-checked on harper main c7572a5): rejectFullTextInGroup (resources/Table.ts ~8915-8921), added by #3927 for relationship groups, calls fullTextComparatorMode on the raw comparator before resolveComparator strips
not_(~8743), sonot_matches*inside rel[...] groups passes the guard: 403-vs-400 attribute-existence oracle, 503 IndexRebuildingError on lmdb/unready indexes, and a late 'Unknown query comparator'. The same one-line change fixes every site:fullTextComparatorMode(resolveComparator(c.comparator).comparator).From QA finding F-1196, finding-triage wave 50.