Skip to content

Improved string escaping for NQL filters and email From headers#28463

Draft
9larsons wants to merge 2 commits into
mainfrom
claude/condescending-heyrovsky-49fb43
Draft

Improved string escaping for NQL filters and email From headers#28463
9larsons wants to merge 2 commits into
mainfrom
claude/condescending-heyrovsky-49fb43

Conversation

@9larsons

@9larsons 9larsons commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Resolves the js/incomplete-sanitization CodeQL alerts around NQL filter and email-header string building. (Split out from the broader code-scanning triage — the GitHub Actions permissions changes are in a separate PR so they can be reviewed in isolation.)

  • gh-resource-select escaped only the first quote in NQL search filters (String.replace with a string argument) — now escapes every quote via a shared helper.
  • The email From name was escaped in email-renderer and again in EmailAddressParser.stringify, double-escaping any title containing a ". The renderer now passes the raw name so stringify is the single escaping point — which also covers the previously unescaped newsletter sender_name.
  • email-address-parser now escapes backslashes before quotes.
  • NQL filter sites that already escape all quotes are left functionally unchanged: NQL reads a lone backslash literally (verified against @tryghost/nql), so doubling backslashes would corrupt legitimate search terms. Added explanatory comments.

Notes

  • No user-facing behavior change, except correctly-formed email From headers for sites whose title or sender name contains a double quote.

Test plan

  • ghost/core email-address-parser + email-renderer unit suites pass; lint-staged eslint clean on all changed files.
  • Email From double-escape fix verified empirically against EmailAddressParser.stringify (raw → stringify yields correct single-escaping).
  • NQL escaping verified empirically against @tryghost/nql: breakout attempts parse as a single name condition, benign terms (O'Brien, a\b) round-trip.

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR adds least-privilege security hardening to four GitHub Actions workflows (CI, Release, Stale, Stale i18n) by introducing explicit permissions blocks. Concurrently, it refactors NQL string escaping across the admin framework and core services: a new escapeNqlString() helper centralizes single-quote escaping logic with documentation, the resource select component adopts this helper for search filtering, multiple services document their escaping patterns, and email address name escaping is consolidated into EmailAddressParser.stringify() while inline escaping is removed from EmailRenderer.

Possibly related PRs

  • TryGhost/Ghost#28194: Adds explicit least-privilege permissions blocks to multiple GitHub Actions workflows with similar structural changes.

Suggested reviewers

  • EvanHahn
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main changes: improved string escaping for NQL filters and email headers, which matches the primary focus of the changeset.
Description check ✅ Passed The description is directly related to the changeset, providing detailed explanations of the string escaping improvements and GitHub Actions permissions changes made across multiple files.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/condescending-heyrovsky-49fb43

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Comment thread ghost/admin/app/components/gh-resource-select.js Fixed

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🧹 Nitpick comments (3)
ghost/core/core/server/services/email-address/email-address-parser.js (1)

49-49: ⚡ Quick win

Add a regression test for mixed backslash+quote display names.

This escaping-order fix is good, but it should be pinned with a unit case that includes both \ and " in name to prevent future reintroductions of double/under-escaping.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/core/core/server/services/email-address/email-address-parser.js` at
line 49, Add a regression unit test for email-address-parser that exercises
mixed backslash+quote display names to lock in the escaping-order fix: construct
an email object whose name contains both backslashes and double quotes (e.g.
contains "\" and \\), call the parsing/formatting function that uses escapedName
(the code path that does email.name.replace(/\\/g, '\\\\').replace(/"/g,
'\\"')), and assert the output matches the expected RFC-safe escaped display
name; put the test alongside existing tests for the email-address-parser module
so future changes to escapedName or the replace order will fail the test if
broken.
ghost/admin/app/components/gh-resource-select.js (1)

14-21: ⚡ Quick win

Consider alignment with apps/posts escaping behavior.

The escapeNqlString helper here only escapes single quotes (' → \'), but the apps/posts implementation (test shown in context) also escapes backslashes when they precede quotes to handle inputs like test\'value correctly. This inconsistency could cause issues if search terms flow between subsystems or if developers expect uniform behavior across the codebase.

If the decision to skip backslash-before-quote escaping is intentional for search UX reasons (to avoid corrupting benign terms), consider adding an explicit note in the comment explaining this tradeoff and that it differs from the apps/posts escaping contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/admin/app/components/gh-resource-select.js` around lines 14 - 21, The
escapeNqlString helper currently only replaces single quotes but should match
the apps/posts behavior: update function escapeNqlString to also escape
backslashes that precede quotes (so inputs like test\'value become safe) and
update the top comment to explain the escaping rule and why it matches
apps/posts; alternatively, if you intentionally want different behavior,
explicitly note that in the comment and describe the tradeoff versus apps/posts’
escaping contract so the inconsistency is documented.
ghost/core/core/server/services/members/members-api/controllers/router-controller.js (1)

1091-1094: 💤 Low value

Clarify why both quote types are escaped here.

The comment mentions "single-quoted NQL filter," but the code escapes both double and single quotes (replace(/("|')/g, '\\$1')), which differs from the other NQL escaping sites in this PR that only escape single quotes. While this is more defensive and matches the test expectations (context snippet), the comment should explain the rationale—for example, that newsletter names may contain either quote type or that the name:[...] array syntax has stricter requirements than the ~'...' search operator.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@ghost/core/core/server/services/members/members-api/controllers/router-controller.js`
around lines 1091 - 1094, Update the comment above the
requestedNewsletterNamesFilter creation to explain why both single and double
quotes are escaped: state that newsletter names can contain either quote
character and that the NQL name:[...] array syntax (used here) requires escaping
both quote types to safely embed names inside single-quoted array items, which
is why replace(/("|')/g, '\\$1') is used instead of only escaping single quotes;
reference the requestedNewsletterNamesFilter variable and the replace(...) call
in the comment so future readers understand the defensive choice and its
relation to the name:[...] vs ~'...' operator differences.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/admin-x-framework/src/hooks/use-filterable-api.ts`:
- Around line 15-22: The function escapeNqlString currently returns an already
quoted string which collides with another implementation that returns only an
escaped value; rename this implementation to escapeAndQuoteNqlString (and update
its callers in use-filterable-api.ts to call the new name) so behavior is
explicit, or alternatively extract a single shared helper (e.g., export
escapeNql and escapeAndQuoteNql from a new common util) and update both this
module and gh-resource-select.js to import and use the appropriate helper; make
sure the symbol names and usages (escapeNqlString, escapeAndQuoteNqlString,
escapeNql) are consistent across callers.

In `@ghost/admin/app/services/labels-manager.js`:
- Around line 76-80: The inline escaping in labels-manager.js (creating safeTerm
via term.replace(/'/g, `\\'`) before using it in the NQL filter should be
replaced with the shared escapeNqlString helper to avoid duplicated logic;
extract escapeNqlString from gh-resource-select.js into a small shared util
(e.g., export function escapeNqlString(term)), import that util into
labels-manager.js, and call escapeNqlString(term) where safeTerm is currently
created so the query uses the shared, single-source escaping logic.

In `@ghost/admin/app/services/tags-manager.js`:
- Around line 26-30: Replace the inline escaping in tags-manager.js with the
shared escapeNqlString helper: remove the manual term.replace(/'/g, `\\'`) and
instead import escapeNqlString from the shared utility (the function currently
used in gh-resource-select.js after you extract it to a shared module), call
const safeTerm = escapeNqlString(term) and pass that into
this.store.query('tag', {filter: `tags.name:~'${safeTerm}'`, limit: 100, page,
order: 'name asc'}); ensure the helper is exported from the new shared util and
the import is added at the top of this file so all NQL escaping is centralized.

---

Nitpick comments:
In `@ghost/admin/app/components/gh-resource-select.js`:
- Around line 14-21: The escapeNqlString helper currently only replaces single
quotes but should match the apps/posts behavior: update function escapeNqlString
to also escape backslashes that precede quotes (so inputs like test\'value
become safe) and update the top comment to explain the escaping rule and why it
matches apps/posts; alternatively, if you intentionally want different behavior,
explicitly note that in the comment and describe the tradeoff versus apps/posts’
escaping contract so the inconsistency is documented.

In `@ghost/core/core/server/services/email-address/email-address-parser.js`:
- Line 49: Add a regression unit test for email-address-parser that exercises
mixed backslash+quote display names to lock in the escaping-order fix: construct
an email object whose name contains both backslashes and double quotes (e.g.
contains "\" and \\), call the parsing/formatting function that uses escapedName
(the code path that does email.name.replace(/\\/g, '\\\\').replace(/"/g,
'\\"')), and assert the output matches the expected RFC-safe escaped display
name; put the test alongside existing tests for the email-address-parser module
so future changes to escapedName or the replace order will fail the test if
broken.

In
`@ghost/core/core/server/services/members/members-api/controllers/router-controller.js`:
- Around line 1091-1094: Update the comment above the
requestedNewsletterNamesFilter creation to explain why both single and double
quotes are escaped: state that newsletter names can contain either quote
character and that the NQL name:[...] array syntax (used here) requires escaping
both quote types to safely embed names inside single-quoted array items, which
is why replace(/("|')/g, '\\$1') is used instead of only escaping single quotes;
reference the requestedNewsletterNamesFilter variable and the replace(...) call
in the comment so future readers understand the defensive choice and its
relation to the name:[...] vs ~'...' operator differences.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2ed67494-bf88-444d-a51c-16e5dfa28bda

📥 Commits

Reviewing files that changed from the base of the PR and between da673e7 and 26fc991.

📒 Files selected for processing (11)
  • .github/workflows/ci.yml
  • .github/workflows/release.yml
  • .github/workflows/stale-i18n.yml
  • .github/workflows/stale.yml
  • apps/admin-x-framework/src/hooks/use-filterable-api.ts
  • ghost/admin/app/components/gh-resource-select.js
  • ghost/admin/app/services/labels-manager.js
  • ghost/admin/app/services/tags-manager.js
  • ghost/core/core/server/services/email-address/email-address-parser.js
  • ghost/core/core/server/services/email-service/email-renderer.js
  • ghost/core/core/server/services/members/members-api/controllers/router-controller.js

Comment on lines +15 to 22
// Escape a value for safe embedding in a single-quoted NQL filter string.
// The NQL lexer only treats `\'`/`\"` as escape sequences and reads a lone
// backslash as a literal character, so escaping every single quote (globally)
// is sufficient to prevent filter breakout. Backslashes must NOT be doubled —
// doing so corrupts benign terms that legitimately contain a backslash.
const escapeNqlString = (value: string) => {
return '\'' + value.replace(/'/g, '\\\'') + '\'';
};

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.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Function name collision with different behavior.

The escapeNqlString function here returns a single-quoted, escaped string ('<escaped>'), but the identically named function in ghost/admin/app/components/gh-resource-select.js returns only the escaped value without quotes (caller adds quotes separately). Having the same function name with different signatures and behavior across the codebase creates confusion and maintenance risk.

Consider one of these approaches:

  • Extract a shared helper into a common utility module (e.g., @tryghost/nql-utils) that both files import
  • Use distinct names (e.g., escapeNqlString vs escapeAndQuoteNqlString) to clarify the behavioral difference
  • Standardize on one approach (escaped-only or escaped-and-quoted) across all implementations
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/admin-x-framework/src/hooks/use-filterable-api.ts` around lines 15 - 22,
The function escapeNqlString currently returns an already quoted string which
collides with another implementation that returns only an escaped value; rename
this implementation to escapeAndQuoteNqlString (and update its callers in
use-filterable-api.ts to call the new name) so behavior is explicit, or
alternatively extract a single shared helper (e.g., export escapeNql and
escapeAndQuoteNql from a new common util) and update both this module and
gh-resource-select.js to import and use the appropriate helper; make sure the
symbol names and usages (escapeNqlString, escapeAndQuoteNqlString, escapeNql)
are consistent across callers.

Comment on lines +76 to 80
// Escape every single quote so the term is safely embedded in the
// single-quoted NQL filter below. NQL reads a lone backslash literally
// (only `\'`/`\"` are escapes), so quotes alone need escaping.
const safeTerm = term.replace(/'/g, `\\'`);
const labels = yield this.store.query('label', {filter: `name:~'${safeTerm}'`, limit: PAGE_SIZE, page, order: 'name asc'});

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.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Consolidate escaping logic to use the shared helper.

This inline escaping (term.replace(/'/g, '\\'))) duplicates the logic now available in the escapeNqlString helper defined in ghost/admin/app/components/gh-resource-select.js (lines 14-21). For maintainability, extract escapeNqlString into a shared utility module and import it here.

This ensures:

  • Single source of truth for NQL string escaping rules
  • Easier updates if escaping requirements change (e.g., handling backslashes before quotes)
  • Consistent behavior across label, tag, and resource search filters
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/admin/app/services/labels-manager.js` around lines 76 - 80, The inline
escaping in labels-manager.js (creating safeTerm via term.replace(/'/g, `\\'`)
before using it in the NQL filter should be replaced with the shared
escapeNqlString helper to avoid duplicated logic; extract escapeNqlString from
gh-resource-select.js into a small shared util (e.g., export function
escapeNqlString(term)), import that util into labels-manager.js, and call
escapeNqlString(term) where safeTerm is currently created so the query uses the
shared, single-source escaping logic.

Comment on lines +26 to 30
// Escape every single quote so the term is safely embedded in the
// single-quoted NQL filter below. NQL reads a lone backslash literally
// (only `\'`/`\"` are escapes), so quotes alone need escaping.
const safeTerm = term.replace(/'/g, `\\'`);
return yield this.store.query('tag', {filter: `tags.name:~'${safeTerm}'`, limit: 100, page, order: 'name asc'});

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.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Consolidate escaping logic to use the shared helper.

This inline escaping duplicates the escapeNqlString helper now defined in ghost/admin/app/components/gh-resource-select.js. Extract the helper into a shared utility module and import it here to maintain a single source of truth for NQL escaping rules and ensure consistent behavior across all search filters.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ghost/admin/app/services/tags-manager.js` around lines 26 - 30, Replace the
inline escaping in tags-manager.js with the shared escapeNqlString helper:
remove the manual term.replace(/'/g, `\\'`) and instead import escapeNqlString
from the shared utility (the function currently used in gh-resource-select.js
after you extract it to a shared module), call const safeTerm =
escapeNqlString(term) and pass that into this.store.query('tag', {filter:
`tags.name:~'${safeTerm}'`, limit: 100, page, order: 'name asc'}); ensure the
helper is exported from the new shared util and the import is added at the top
of this file so all NQL escaping is centralized.

@codecov

codecov Bot commented Jun 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 38.46154% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.75%. Comparing base (7581df4) to head (0cc5bbf).
⚠️ Report is 33 commits behind head on main.

Files with missing lines Patch % Lines
...mbers/members-api/controllers/router-controller.js 0.00% 5 Missing ⚠️
ghost/admin/app/components/gh-resource-select.js 33.33% 2 Missing ⚠️
...re/server/services/email-service/email-renderer.js 75.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #28463      +/-   ##
==========================================
+ Coverage   73.74%   73.75%   +0.01%     
==========================================
  Files        1541     1541              
  Lines      132180   132289     +109     
  Branches    15784    15804      +20     
==========================================
+ Hits        97476    97576     +100     
+ Misses      33737    33724      -13     
- Partials      967      989      +22     
Flag Coverage Δ
admin-tests 54.66% <33.33%> (-0.01%) ⬇️
e2e-tests 73.75% <38.46%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@9larsons 9larsons marked this pull request as draft June 10, 2026 13:45
no ref

- CodeQL (js/incomplete-sanitization) flagged several filter/header builders with incomplete escaping
- gh-resource-select escaped only the first quote (String.replace with a string argument); it now escapes every quote via a shared helper
- the email From name was escaped in email-renderer AND again in EmailAddressParser.stringify, double-escaping any title containing a double quote; the renderer now passes the raw name so stringify is the single escaping point (this also covers the previously unescaped newsletter sender_name)
- email-address-parser now escapes backslashes before quotes
- the NQL sites that already escaped all quotes are left functionally unchanged: NQL reads a lone backslash literally (verified against @tryghost/nql), so doubling backslashes would corrupt legitimate search terms; added comments explaining this
@9larsons 9larsons force-pushed the claude/condescending-heyrovsky-49fb43 branch from 26fc991 to 6d16fda Compare June 10, 2026 15:20
@9larsons 9larsons changed the title Resolved CodeQL code-scanning findings: workflow permissions and string escaping Improved string escaping for NQL filters and email From headers Jun 10, 2026
no ref

- gh-resource-select escapeNqlString now returns the quoted string, matching the escapeNqlString contract used in apps/posts and admin-x-framework (it previously returned the unquoted value, a same-name/different-behavior collision flagged in review)
- added a regression test for email-address-parser covering a name containing both a backslash and a double quote, locking in the escape-once ordering
- expanded the router-controller comment to explain why the name:[...] array syntax escapes both quote characters
@9larsons

Copy link
Copy Markdown
Contributor Author

Thanks for the review — pushed 0cc5bbf addressing it. One point is worth surfacing because it's the crux:

Escaping backslashes (the apps/posts comparison)

I verified empirically against @tryghost/nql (0.12.11). For the ~ operator, doubling backslashes — as apps/posts's escapeNqlString does — actually corrupts the search: a term a\b is searched as a\\b, because NQL's lexer reads a lone backslash literally and its unescape only collapses \'/\", never \\. Quote-only escaping preserves the term, and neither approach allows filter breakout. So matching apps/posts here would introduce a data bug rather than fix one — I've documented that rationale in the helper comments.

That also means the CodeQL does not escape backslash alerts on these NQL sites are false positives; they're dismissed with that justification. (The apps/posts helper appears to have this latent backslash-corruption bug — flagging separately, out of scope here.)

Addressed in 0cc5bbf

  • email-address-parser: added a regression test for a name containing both \ and " (locks in the escape-once ordering).
  • router-controller: expanded the comment explaining why the name:[...] array syntax escapes both quote characters.
  • gh-resource-select: escapeNqlString now returns the quoted form, resolving the same-name/different-contract collision flagged below.

@9larsons

Copy link
Copy Markdown
Contributor Author

On the suggestion to consolidate the NQL escaping into a shared helper (the inline labels-manager / tags-manager comments):

I kept these inline and individually documented rather than extracting a shared util. It's a single intentional line per site, and a helper imported across an Ember component plus two services adds indirection for little gain. With 0cc5bbf the contract is also now consistent — gh-resource-select, apps/posts, and admin-x-framework all expose an escapeNqlString that returns the quoted form.

Happy to extract a ghost/admin/app/utils/nql.js single source of truth for the three Ember sites if you'd prefer it — just say the word and I'll add it.

(Replying here rather than inline — the REST review-reply endpoint is returning 401 for me at the moment.)

// every quote prevents breakout, and backslashes must NOT be doubled (doubling
// corrupts terms containing a backslash, e.g. `a\b` would be searched as `a\\b`).
function escapeNqlString(term) {
return '\'' + String(term).replace(/'/g, '\\\'') + '\'';
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.

2 participants