Skip to content

fix: correct ACL mapping, indexed URL, credential exposure and crawl lifecycle - #27

Merged
marevol merged 9 commits into
fix/dependency-modernizationfrom
fix/acl-and-url-correctness
Aug 31, 2026
Merged

marevol merged 9 commits into
fix/dependency-modernizationfrom
fix/acl-and-url-correctness

Conversation

@marevol

@marevol marevol commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Eight correctness defects in the Drive datastore, each pinned by a test written first. One is a
credential-disclosure issue; two silently dropped access-control grants; one made crawls
uninterruptible.

Depends on #26 — please merge that first.

The defects

1. Publicly-shared files were unreachable by anonymous users (fix:)

getPermission(type, value) opened with if (value == null) return null; and was called with
permission.getEmailAddress(). A type=anyone permission carries no email address, so the
anyone branch was unreachable dead code — public files indexed with that grant dropped.
It now resolves before the null guard and maps to the anonymous role via
getSearchRoleByRole("guest"), which is the role a real guest session actually carries.

2. Domain-shared files lost their ACL entirely (fix:)

A type=domain permission carries its value in getDomain(), not getEmailAddress(), so this
branch was unreachable for the same reason. It now reads the right field, and the role format is
configurable via a new domain_permission_format parameter (default {group}{domain}).

The formatted value is passed through PermissionHelper.encode(), the same way
default_permissions already is in this class. Without that the index would receive the literal
string {group}example.com — a value no user's search-time role can match, which would have turned
"grant dropped" into "grant unmatched" rather than fixing anything.

3. The service account's private key was reachable from crawl scripts (fix:) — security

processFile seeded the script-evaluation context with the entire parameter map and stripped
nothing, so a crawl script such as digest=private_key would index the service account's RSA
private key into the search index, readable by anyone with search access.

private_key, private_key_id and client_email are now removed before the context is used, via
a named SECRET_PARAM_KEYS constant so a future secret parameter has one obvious place to be added.
The test proves the leak is closed behaviourally — a script referencing private_key resolves to
null — rather than counting map keys.

4. Indexed URL is now the browser-openable link (fix:)

getUrl returned webContentLink, a direct-download link, falling back to a
uc?id=...&export=download URL. Neither opens the document. Every example in the documentation
overrode it with url=file.web_view_link, which was the tell. The chain is now
webViewLink → https://drive.google.com/open?id=<id> → null.

5. Crawler stats key raced across threads (fix:)

The stats key was put on the shared parameter map, so with number_of_threads > 1 the threads
clobbered each other. It now goes on a per-thread copy; the shared map is no longer mutated.

6. Oversized files were downloaded and parsed before being rejected (fix:)

max_size was only checked after the content had been fetched and run through Tika. The size Drive
reports is now checked first. Google-native formats report no size, so those still fall back to the
post-extraction check — skipping them outright would lose all Docs, Sheets and Slides content.

7. Crawls could not be stopped (fix:)

AbstractDataStore.alive was never read, so the admin console's force-stop
(isForceStop() → stopCrawling() → dataStore.stop()) had no effect. It is now checked in the
dispatch loop and at the top of processFile, and the hardcoded 60-second shutdown wait becomes a
thread_pool_timeout_seconds parameter.

Known limitation: stopping prevents new work from being dispatched, but GSuiteClient.getFiles
still walks the remaining pages of the listing because list.execute() blocks. On a large Drive a
force-stop therefore takes effect later than an operator might expect. Interrupting the client's own
paging belongs with the rate-limit work that rewrites that loop, and is deliberately not done here
rather than rewriting getFiles twice.

8. Shared-drive downloads could fail (fix:)

files.get never set supportsAllDrives. It does now.

Note Drive.Files.Export has no such parameter — only Drive.Files.Get does — so the export path
used for Google-native formats cannot carry the flag. A comment on extractFileText records why.

Verification

mvn clean verify
Tests run: 79, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

That run includes the javadoc:jar step. Test count across the stack so far: 0 → 41 → 42 → 50 → 79.

Behaviour changes for existing users

  • The indexed URL changes from the download link to webViewLink. Existing indexes carry the old
    URL, so a full re-crawl is needed to pick up the new value.
  • Publicly-shared and domain-shared files now carry grants they previously lost. Documents that
    were effectively invisible to anonymous or domain users will start appearing for them — this is
    the fix working, but it is a visible change in search results.
  • New parameters: domain_permission_format, thread_pool_timeout_seconds.

A type=anyone Drive permission means "anyone with the link". Fess identifies the
anonymous user with the guest role (role.search.guest.permissions), so the
previous getSearchRoleByUser("guest") produced a user-typed role and publicly
shared files were invisible to anonymous searches.

A type=anyone permission also carries no emailAddress, so the branch was
unreachable behind the null-value guard. Resolve anyone before that guard.
A type=domain Drive permission carries the domain in the domain field and leaves
emailAddress null, so the previous getEmailAddress()-based branch was dead code
and domain-wide shares were dropped from the indexed ACL entirely.

Resolve them through the new domain_permission_format parameter, which defaults
to {group}{domain}.
getDomainPermission returned the {domain}-substituted format string verbatim,
so with the default format the indexed role was the literal text
"{group}example.com" rather than a role any user's search-time roles could
match. That moved D-3 from "grant dropped entirely" to "grant indexed in a
form nothing can match", which is not a real fix.

Pass the substituted value through PermissionHelper#encode(), the same call
default_permissions already goes through a few lines away in this class. The
{user}/{group}/{role} tokens in domain_permission_format are now input
notation for encode(), consistent with default_permissions, and the method
returns null instead of an unusable role when encode() cannot resolve the
format.
The indexed URL defaulted to webContentLink, a direct-download link, and fell
back to a uc?export=download URL. Search results therefore pointed at downloads
instead of the Drive viewer, which is why every documented script mapping had to
override url with file.web_view_link.

Use webViewLink, falling back to https://drive.google.com/open?id=<id>.

Existing indexes carry the old URL; a full re-crawl is required to pick up the
new value.
Drive rejects files.get for an item that lives on a shared drive unless
supportsAllDrives=true is set, so content downloads from shared drives could
fail. Route the download through a small request factory that sets the flag.

files.export has no supportsAllDrives parameter in the Drive v3 API, so
extractFileText only gains a note explaining why the flag is absent there.
processFile runs on the crawler thread pool but wrote the stats key into the
DataStoreParams instance shared by every thread, so with number_of_threads > 1
concurrent files overwrote each other's stats key and the callback could be
handed another file's key.

Copy the params per call and use the copy for the stats key, the script context,
the permissions lookup and the index callback.
processFile seeded the script evaluation context with the whole parameter map
and removed nothing, so a mapping such as digest=private_key indexed the
service account RSA private key into the search index, where anyone with
search access could read it.

Remove private_key, private_key_id and client_email from the context before it
reaches convertValue, mirroring what GitDataStore does for username/password.
The three keys live in one named constant, SECRET_PARAM_KEYS, so a later
secret parameter has an obvious place to be added.
The size guard ran after getFileContents, so a file above max_size was still
downloaded from Drive and run through Tika before being discarded.

Check File#getSize() first and fall back to the post-extraction check only for
Google native formats, which report no size.
AbstractDataStore#alive was never read, so the admin UI's force stop
(DataIndexHelper -> stopCrawling -> DataStore#stop) had no effect: every
remaining file was still queued and indexed.

Check alive when dispatching each file and again at the top of processFile, and
make the hardcoded 60 second awaitTermination configurable through
thread_pool_timeout_seconds.
@marevol
marevol force-pushed the fix/acl-and-url-correctness branch from c1377a4 to b6b603c Compare August 31, 2026 14:01
@marevol
marevol merged commit 613ee31 into master Aug 31, 2026
1 check passed
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.

1 participant