GitHub Security Advisory: GHSA-xhq9-58fw-859p
Package: apostrophe (NPM)
Severity: MODERATE (CVSS 5.3)
CWE: CWE-200: Exposure of Sensitive Information to an Unauthorized Actor, CWE-863: Incorrect Authorization
Vulnerable versions: < 4.29.0
Patched in: 4.29.0

Summary: ApostropheCMS: publicApiProjection Bypass via project Query Builder in Piece-Type REST API

Details:
## Summary

The `getRestQuery` method in the `@apostrophecms/piece-type` module checks whether a MongoDB projection has already been set before applying the admin-configured `publicApiProjection`. An unauthenticated attacker can supply a `project` query parameter in the REST API request to pre-populate the projection state, causing the security-enforced `publicApiProjection` to be skipped entirely. This allows disclosure of fields that the site administrator explicitly restricted from public access.

## Details

When an unauthenticated user queries the piece-type REST API, the `getRestQuery` method processes the request at `modules/@apostrophecms/piece-type/index.js:1120`:

```javascript
// piece-type/index.js:1120-1137
getRestQuery(req, omitPermissionCheck = false) {
  const query = self.find(req).attachments(true);
  query.applyBuildersSafely(req.query);          // [1] attacker input applied first
  if (!omitPermissionCheck && !self.canAccessApi(req)) {
    if (!self.options.publicApiProjection) {
      query.and({
        _id: null
      });
    } else if (!query.state.project) {            // [2] checks if projection already set
      query.project({
        ...self.options.publicApiProjection,
        cacheInvalidatedAt: 1
      });
    }
  }
  return query;
},
```

At **[1]**, `applyBuildersSafely` iterates over all query string parameters and invokes their corresponding builder methods. The `project` builder exists in `@apostrophecms/doc-type` with a `launder` method (`doc-type/index.js:1876`) that sanitizes values to booleans:

```javascript
// doc-type/index.js:1875-1889
project: {
  launder (p) {
    if (!p || typeof p !== 'object' || Array.isArray(p)) {
      return {};
    }
    const projection = Object.entries(p).reduce((acc, [ key, val ]) => {
      return {
        ...acc,
        [key]: self.apos.launder.boolean(val)
      };
    }, {});
    return projection;
  },
```

When a request includes `?project[someField]=1`, the builder sets `query.state.project` to `{someField: true}`. At **[2]**, the conditional `!query.state.project` evaluates to `false` because the state is already populated, so the `publicApiProjection` is never applied.

For comparison, the `@apostrophecms/page` module's equivalent method (`page/index.js:2953`) unconditionally applies the projection:

```javascript
// page/index.js:2953-2958
} else {
  query.project({
    ...self.options.publicApiProjection,
    cacheInvalidatedAt: 1
  });
}
```

## PoC

**Prerequisites:** An ApostropheCMS 4.x instance with a piece-type (e.g., `article`) that has `publicApiProjection` configured to restrict fields. For example:

```javascript
// modules/article/index.js
module.exports = {
  extend: '@apostrophecms/piece-type',
  options: {
    publicApiProjection: {
      title: 1,
      _url: 1
    }
  }
};
```

**Step 1:** Normal request — observe restricted fields are hidden:

```bash
curl 'http://localhost:3000/api/v1/article'
```

Response returns only `title` and `_url` fie