Skip to content

Improved restricted field filter validation in API endpoints#26750

Merged
kevinansfield merged 1 commit intomainfrom
restricted-field-filter-validation
Mar 10, 2026
Merged

Improved restricted field filter validation in API endpoints#26750
kevinansfield merged 1 commit intomainfrom
restricted-field-filter-validation

Conversation

@kevinansfield
Copy link
Copy Markdown
Member

The filter field validation was only checking the last segment of
dot-separated keys. Updated to check all segments so that compound
keys are handled correctly. Also added restricted field filtering
to the Admin API users endpoint, and renamed the utility module
to better reflect its broader usage across API layers.

The filter field validation was only checking the last segment of
dot-separated keys. Updated to check all segments so that compound
keys are handled correctly. Also added restricted field filtering
to the Admin API users endpoint, and renamed the utility module
to better reflect its broader usage across API layers.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 10, 2026

Walkthrough

This pull request refactors field-level filtering for public and admin API endpoints. It introduces a new utility module api-filter-utils.ts that provides two transformers: rejectContentApiRestrictedFieldsTransformer (restricts password and email fields) and rejectAdminApiRestrictedFieldsTransformer (restricts password field). The authors-public, pages-public, and posts-public endpoints are updated to use the content API transformer in their MongoDB queries. The users endpoint is updated to apply the admin API transformer to browse queries and removes the 'filter' option from allowed options. The previous rejectPrivateFieldsTransformer is removed from public-endpoint-utils. New test cases verify that filtering by restricted fields (password and nested password paths) is not supported.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: improving restricted field filter validation in API endpoints across multiple files.
Description check ✅ Passed The description is directly related to the changeset, explaining the validation fix for dot-separated keys and addition of filtering to the Admin API users endpoint.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch restricted-field-filter-validation

Comment @coderabbitai help to get the list of available commands and usage tips.

@kevinansfield kevinansfield enabled auto-merge (squash) March 10, 2026 11:46
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
ghost/core/test/legacy/api/content/posts.test.js (1)

104-142: Please add the equivalent nested email bypass case.

ghost/core/core/server/api/endpoints/utils/api-filter-utils.ts now protects both password and email, but this new regression only covers the dotted password path. A matching authors.email.x case would keep the second restricted field from drifting untested.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/legacy/api/content/posts.test.js` around lines 104 - 142, Add
a new test mirroring "can not filter posts by authors.password.x (3-segment
bypass)" that checks the same 3-segment dotted-path bypass for email: insert a
test user (reuse userId pattern and create a post-author relation as in the
existing test) with a known email value, then perform the API request against
posts/?filter=authors.email.x:'<that-email>' (using localUtils.API.getApiQuery
and validKey), assert that the response does NOT return the post (throw if
data.posts.length === 1), and finally remove the posts_authors and users rows in
the same finally cleanup block; use the same test structure and expectations
(Content-Type, Cache-Control, expect 200) and mirror the unique identifiers used
in the existing test to locate where to add this new test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ghost/core/test/legacy/api/content/posts.test.js`:
- Around line 108-141: The test's fixture setup (inserts into users,
posts_authors and fetching postId) occurs before the try/finally, so if any
setup step throws the finally cleanup never runs and rows leak; move the setup
(the user insert, the posts query/assignment to postId, and the posts_authors
insert) inside the try block that currently wraps the request so that the
finally always executes the cleanup; reference the existing testUtils.knex calls
(users insert, posts.first('id').where('slug','welcome'), posts_authors.insert
with id '644fd18ca1f0b764b0279b2f') and keep the existing finally that deletes
from posts_authors and users.

---

Nitpick comments:
In `@ghost/core/test/legacy/api/content/posts.test.js`:
- Around line 104-142: Add a new test mirroring "can not filter posts by
authors.password.x (3-segment bypass)" that checks the same 3-segment
dotted-path bypass for email: insert a test user (reuse userId pattern and
create a post-author relation as in the existing test) with a known email value,
then perform the API request against
posts/?filter=authors.email.x:'<that-email>' (using localUtils.API.getApiQuery
and validKey), assert that the response does NOT return the post (throw if
data.posts.length === 1), and finally remove the posts_authors and users rows in
the same finally cleanup block; use the same test structure and expectations
(Content-Type, Cache-Control, expect 200) and mirror the unique identifiers used
in the existing test to locate where to add this new test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c5256d52-1fa7-4ea9-acfb-13bcb84061e2

📥 Commits

Reviewing files that changed from the base of the PR and between 292bdc3 and 5796401.

📒 Files selected for processing (8)
  • ghost/core/core/server/api/endpoints/authors-public.js
  • ghost/core/core/server/api/endpoints/pages-public.js
  • ghost/core/core/server/api/endpoints/posts-public.js
  • ghost/core/core/server/api/endpoints/users.js
  • ghost/core/core/server/api/endpoints/utils/api-filter-utils.ts
  • ghost/core/core/server/api/endpoints/utils/public-endpoint-utils.ts
  • ghost/core/test/e2e-api/admin/users.test.js
  • ghost/core/test/legacy/api/content/posts.test.js
💤 Files with no reviewable changes (1)
  • ghost/core/core/server/api/endpoints/utils/public-endpoint-utils.ts

Comment on lines +108 to +141
await testUtils.knex('users').insert({
id: userId,
slug: 'brute-force-password-test-user',
name: 'Brute Force Password Test User',
email: 'bruteforcepasswordtestuseremail@example.com',
password: hashedPassword,
status: 'active',
created_at: '2019-01-01 00:00:00'
});

const {id: postId} = await testUtils.knex('posts').first('id').where('slug', 'welcome');

await testUtils.knex('posts_authors').insert({
id: '644fd18ca1f0b764b0279b2f',
post_id: postId,
author_id: userId
});

try {
const res = await request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}&filter=authors.password.x:'${hashedPassword}'`))
.set('Origin', testUtils.API.getURL())
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.public)
.expect(200);

const data = JSON.parse(res.text);

if (data.posts.length === 1) {
throw new Error('3-segment key bypass should not return filtered results');
}
} finally {
await testUtils.knex('posts_authors').where('id', '644fd18ca1f0b764b0279b2f').del();
await testUtils.knex('users').where('id', userId).del();
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Wrap the fixture setup in the try block.

If one of the arrange steps throws after the user insert succeeds, the cleanup never runs and this test can leak rows into later cases.

🧹 Suggested fix
-        await testUtils.knex('users').insert({
-            id: userId,
-            slug: 'brute-force-password-test-user',
-            name: 'Brute Force Password Test User',
-            email: 'bruteforcepasswordtestuseremail@example.com',
-            password: hashedPassword,
-            status: 'active',
-            created_at: '2019-01-01 00:00:00'
-        });
-
-        const {id: postId} = await testUtils.knex('posts').first('id').where('slug', 'welcome');
-
-        await testUtils.knex('posts_authors').insert({
-            id: '644fd18ca1f0b764b0279b2f',
-            post_id: postId,
-            author_id: userId
-        });
-
         try {
+            await testUtils.knex('users').insert({
+                id: userId,
+                slug: 'brute-force-password-test-user',
+                name: 'Brute Force Password Test User',
+                email: 'bruteforcepasswordtestuseremail@example.com',
+                password: hashedPassword,
+                status: 'active',
+                created_at: '2019-01-01 00:00:00'
+            });
+
+            const {id: postId} = await testUtils.knex('posts').first('id').where('slug', 'welcome');
+
+            await testUtils.knex('posts_authors').insert({
+                id: '644fd18ca1f0b764b0279b2f',
+                post_id: postId,
+                author_id: userId
+            });
+
             const res = await request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}&filter=authors.password.x:'${hashedPassword}'`))
                 .set('Origin', testUtils.API.getURL())
                 .expect('Content-Type', /json/)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await testUtils.knex('users').insert({
id: userId,
slug: 'brute-force-password-test-user',
name: 'Brute Force Password Test User',
email: 'bruteforcepasswordtestuseremail@example.com',
password: hashedPassword,
status: 'active',
created_at: '2019-01-01 00:00:00'
});
const {id: postId} = await testUtils.knex('posts').first('id').where('slug', 'welcome');
await testUtils.knex('posts_authors').insert({
id: '644fd18ca1f0b764b0279b2f',
post_id: postId,
author_id: userId
});
try {
const res = await request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}&filter=authors.password.x:'${hashedPassword}'`))
.set('Origin', testUtils.API.getURL())
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.public)
.expect(200);
const data = JSON.parse(res.text);
if (data.posts.length === 1) {
throw new Error('3-segment key bypass should not return filtered results');
}
} finally {
await testUtils.knex('posts_authors').where('id', '644fd18ca1f0b764b0279b2f').del();
await testUtils.knex('users').where('id', userId).del();
}
try {
await testUtils.knex('users').insert({
id: userId,
slug: 'brute-force-password-test-user',
name: 'Brute Force Password Test User',
email: 'bruteforcepasswordtestuseremail@example.com',
password: hashedPassword,
status: 'active',
created_at: '2019-01-01 00:00:00'
});
const {id: postId} = await testUtils.knex('posts').first('id').where('slug', 'welcome');
await testUtils.knex('posts_authors').insert({
id: '644fd18ca1f0b764b0279b2f',
post_id: postId,
author_id: userId
});
const res = await request.get(localUtils.API.getApiQuery(`posts/?key=${validKey}&filter=authors.password.x:'${hashedPassword}'`))
.set('Origin', testUtils.API.getURL())
.expect('Content-Type', /json/)
.expect('Cache-Control', testUtils.cacheRules.public)
.expect(200);
const data = JSON.parse(res.text);
if (data.posts.length === 1) {
throw new Error('3-segment key bypass should not return filtered results');
}
} finally {
await testUtils.knex('posts_authors').where('id', '644fd18ca1f0b764b0279b2f').del();
await testUtils.knex('users').where('id', userId).del();
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/test/legacy/api/content/posts.test.js` around lines 108 - 141, The
test's fixture setup (inserts into users, posts_authors and fetching postId)
occurs before the try/finally, so if any setup step throws the finally cleanup
never runs and rows leak; move the setup (the user insert, the posts
query/assignment to postId, and the posts_authors insert) inside the try block
that currently wraps the request so that the finally always executes the
cleanup; reference the existing testUtils.knex calls (users insert,
posts.first('id').where('slug','welcome'), posts_authors.insert with id
'644fd18ca1f0b764b0279b2f') and keep the existing finally that deletes from
posts_authors and users.

@kevinansfield kevinansfield merged commit 52c33e3 into main Mar 10, 2026
31 checks passed
@kevinansfield kevinansfield deleted the restricted-field-filter-validation branch March 10, 2026 12:14
peterzimon pushed a commit that referenced this pull request Mar 10, 2026
ref https://linear.app/ghost/issue/ONC-1525/

The filter field validation was only checking the last segment of
dot-separated keys. Updated to check all segments so that compound
keys are handled correctly. Also added restricted field filtering
to the Admin API users endpoint, and renamed the utility module
to better reflect its broader usage across API layers.
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