Skip to content

Full-text search: negated comparators bypass the relationship guard, relationship-path sort skips the full-text field check, and tables without a @fullText index return the wrong error #3871

Open

Description

@kriszyp

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:

  1. 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's rejectUnsupported guard (resources/Table.ts:7893-7908):

    • In Table._prepareRelationshipSelection (resources/Table.ts:7893-7900), rejectUnsupported checks else if (fullTextComparatorMode(condition.comparator)) at :7896 before targetTable._prepareQueryConditions (:7901-7907) calls resolveComparator(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 returns undefined for all 6 'not_matches*' comparators (unlike resources/search.ts:2626, which calls fullTextComparatorMode(resolveComparator(comparator).comparator)), negated full-text predicates in relationship select.conditions bypass rejectUnsupported:
      • Sub-case 1a: On Owner.search and Owner.get with select: ['id', { name: 'products', conditions: [{ attribute: 'catalogSearch', comparator: 'not_matches*', value: 'shoe' }], select: ['id'] }] (both top-level and nested inside { operator: 'and', conditions: [...] }), Product._prepareQueryConditions strips 'not_' and compile() (:7911) calls filterByType (resources/search.ts:2073), failing with 400 ClientError: Unknown query comparator "matches..." instead of 400 ClientError: Full-text predicates are not supported in relationship selections.
      • Sub-case 1b: When a restricted user with attribute_permissions denying Product.secret queries Owner (checkPermission: true) with conditions: [{ attribute: 'secretSearch', comparator: 'not_matches', value: 'classified' }], positive 'matches' rejects at :7897 with 400 Full-text predicates are not supported in relationship selections, whereas 'not_matches' executes Product._prepareQueryConditions (:7901 -> assertFullTextReadAccess at :8229) and throws 403 AccessViolation: Unauthorized access to resource — an authorization oracle leak revealing whether secretSearch indexes a restricted source attribute on Product.
  2. Arm 2 — Relationship-path sort (sort: { attribute: ['product', 'catalogSearch'] } and ['products', 'catalogSearch']) on Table.search (resources/Table.ts:7556-7561) and nested relationship select.sort (resources/Table.ts:2208-2211) bypass assertFullTextRecordField:

    • Both Table.search (resources/Table.ts:7557: if (fullTextFieldNames) assertRecordField(order.attribute)) and Table.assertFullTextSelection(select, sort) (resources/Table.ts:2210: for (let order = sort; order; order = order.next) assertRecordField(order.attribute)) call assertRecordField(order.attribute) (:12027-12034), which only checks order.attribute[0] against the current table's fullTextFieldNames (and in Table.search is skipped completely when the queried table has fullTextFieldNames === undefined), instead of assertFullTextRecordField(order.attribute) (:12036-12044), which traverses propertyResolvers[first]?.definition?.tableClass?.assertFullTextRecordField:
      • Sub-case 2a: On Table.search (both on Owner with fullTextFieldNames === undefined and AuthorWithFullText with fullTextFieldNames !== undefined), secondary sort sort: { attribute: 'id', next: { attribute: ['product', 'catalogSearch'] } } and operator: 'or' primary sort sort: { attribute: ['product', 'catalogSearch'] } silently succeed and return all rows instead of throwing 400 Full-text field "catalogSearch" is query-only, while unaligned AND primary sort falls through to findAttribute (:7575) and throws 404 Error: product.catalogSearch is not a defined attribute instead of 400 Full-text field "catalogSearch" is query-only.
      • Sub-case 2b: On 2-hop nested relationship select.sort (Store.search and Store.get with select: ['id', { name: 'owners', select: ['id'], sort: { attribute: ['product', 'catalogSearch'] } }]), Owner.assertFullTextSelection at :2210 calls assertRecordField instead of assertFullTextRecordField, silently succeeding and returning all rows instead of throwing 400 Full-text field "catalogSearch" is query-only.
  3. Arm 3 — _prepareQueryConditions on tables with fullTextIndexes.length === 0 (resources/Table.ts:8208-8222) forces fullTextMode = undefined for string attributes:

    • At resources/Table.ts:8208-8213, fullTextMode is gated by TableResource.fullTextIndexes.length > 0 || Array.isArray(attribute_name) || (typeof attribute_name === 'string' && fullTextFieldNames?.has(attribute_name)). On any table without a @fullText index (PlainItem), fullTextMode is forced to undefined for string attributes:
      • Sub-case 3a: { attribute: 'title', comparator: 'matches_fuzzy' | 'matches_fuzzy_prefix' | 'not_matches_fuzzy', value: 'shoe', fuzzyDistance: 1 } (or fuzzyTranspositions: false) calls validateFullTextFuzzyOptions(condition, undefined) (:8219) and throws the self-contradictory 400 ClientError: Fuzzy controls require matches_fuzzy or matches_fuzzy_prefix on a full-text index condition instead of 400 ClientError: Full-text comparator requires a declared @fullText index.
      • Sub-case 3b: A non-leading full-text condition in an AND query ([{ attribute: 'id', comparator: 'equals', value: 'item-1' }, { attribute: 'title', comparator: 'matches' | 'not_matches', value: 'shoe' }]) skips throwUnknownFullTextIndex (:8222), runs the index lookup, and fails inside filterByType (resources/search.ts:2073) with 400 ClientError: Unknown query comparator "matches" instead of 400 ClientError: Full-text comparator requires a declared @fullText index.
      • Sub-case 3c: For a restricted attribute_permissions user querying { attribute: 'secret', comparator: 'matches', value: 'classified' } or { attribute: 'unknownAttr', comparator: 'matches', value: 'x' } on PlainItem, skipping throwUnknownFullTextIndex (:8222 -> :12098-12103) leaks 400 Full-text comparator requires a declared @fullText index (on 'secret') and 404 unknownAttr is not a defined attribute (on 'unknownAttr') instead of throwing 403 AccessViolation.

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.js

Counterfactual Fix Verified in Gate 2 (resources/Table.ts)

  1. In Table.assertFullTextSelection (resources/Table.ts:2210), change assertRecordField(order.attribute) to assertFullTextRecordField(order.attribute).
  2. In Table.search (resources/Table.ts:7557), change if (fullTextFieldNames) assertRecordField(order.attribute) to if (fullTextFieldNames || hasRelationships) assertFullTextRecordField(order.attribute).
  3. In Table._prepareRelationshipSelection (resources/Table.ts:7896), change else if (fullTextComparatorMode(condition.comparator)) to else if (fullTextComparatorMode(resolveComparator(condition.comparator).comparator)).
  4. In Table._prepareQueryConditions (resources/Table.ts:8208-8213), change const fullTextMode = ... ? fullTextComparatorMode(condition.comparator) : undefined to const fullTextMode = fullTextComparatorMode(condition.comparator).

With this 4-site change in resources/Table.ts:

  • QA_ASSERT_MODE=contract passes 16 passing (15s) on rocksdb and 16 passing (1s) on lmdb.
  • QA_ASSERT_MODE=defect fails all 13 defect tests (3 passing, 13 failing).
  • Existing full-text suites (fullTextFields.test.js, fullTextSchema.test.js, fullTextSearchLimits.test.js) pass 64 passing, 0 failing.

Existing Work & Scope

  • Searched HarperFast/harper and HarperFast/harper-pro issues 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-190 on 6048a06709a7bff15b6d5732fb0b3ecdc69b8098 and HarperFast/documentation at fc17e9ddb92ad5a1ecc193e6017b680643a4a19b (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) before Table.search on a table with fullTextIndexes.length === 0 (PlainItem) were not tested end-to-end over HTTP here; Table.allowRead (:4300) also gates FULL_TEXT_READ_PERMISSION caching on TableResource.fullTextIndexes.length > 0, whereas getTablePermissions (:12018-12025) falls back to user?.role?.permission when user is present on context.

Activity

  1. added theissue type on Nov 2, 2026
  2. kriszyp commented on Nov 10, 2026

    @kriszyp
    MemberAuthor

    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), so not_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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    Priority

    P2

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions