Skip to content

fix(notifications): require a valid code, and escape the upstream path - #91

Merged
feruzm merged 4 commits into
mainfrom
fix/notifications-auth-and-path-escaping
Sep 1, 2026
Merged

fix(notifications): require a valid code, and escape the upstream path#91
feruzm merged 4 commits into
mainfrom
fix/notifications-auth-and-path-escaping

Conversation

@feruzm

@feruzm feruzm commented Sep 1, 2026

Copy link
Copy Markdown
Member

Closes half of #90. Two defects in PrivateApi.Notifications.

1. A code was not actually required

if (string.IsNullOrEmpty(username))
{
    if (!JsJson.IsTruthy(user)) { await ctx.SendText(401, "Unauthorized"); return; }
    username = UserData1Helpers.Template(user);
}

The 401 was only reachable when user was absent. Supplying it satisfied the guard and became the account queried, so the endpoint served notifications with no authentication at all.

A valid code is now required before anything else is read, matching UnreadNotifications directly below.

2. The upstream path was built by unescaped interpolation

username, filter, since and limit all went into the path through Template(), which emulates JS string coercion and performs no URL encoding. A value carrying /, ? or # was therefore re-parsed as URL structure once the string became a Uri, reaching a different upstream endpoint with this service's credentials attached.

That is what left the api-proxy per-path allowlist acting as a security control rather than as routing hygiene.

NotificationsPath() now escapes every segment and rejects dot segments, exactly as PostTipsPath() already does for the tips handlers and for the same reason. Escaping is a no-op for real values: account names, filter names, notification ids and integer limits are all unreserved characters, so live requests are byte-identical.

3. Cross-account views are downgraded to the restricted feed

A caller with a valid code can still name another account via user. Decks depends on this: its notifications column is built from a free-text account search box and passes settings.username alongside the signed-in user's own code. Notifications are largely public data and that column exists for that reason, so it stays.

But the feed also carries Ecency-only activity that is not public: favorites and bookmarks reveal who a user follows and what they saved, and Points transfers, streaks and the aggregates exist nowhere on chain.

So only a self-view asks for scope=full. A cross-account view sends no scope parameter, and enotify (ecency/enotify-py#21) defaults to chain-derived activity only.

Omitting the parameter is deliberately the safe direction: any request that never reaches this handler gets the restricted feed rather than the whole one. scope is derived from the validated code and never read from the body, so a caller cannot ask for a wider view than they are entitled to.

Companion: ecency/vision-web#1709 hides the now-restricted types in the Decks picker so nobody builds a column that cannot load.

Deploy order

Ship this before ecency/enotify-py#21. scope=full is ignored until enotify's change lands, so there is no window where a user loses their own favorites, bookmarks or aggregates.

Tests

NotificationsPathTests mirrors PostTipsPathTests:

  • real requests unchanged, so this cannot silently alter live traffic
  • structural characters cannot escape their segment
  • dot segments rejected, since Uri decodes %2E back to . before removing them
  • query values cannot append parameters of their own
  • limit still joins with & when since is present and ? when it is not, preserving the original URL shape for existing paging clients

Verification note

There is no dotnet SDK on the machine this was written on, so the build and tests were not run locally. CI is the gate, and it is green: Failed: 0, Passed: 206. Escaping expectations were checked against the unreserved-character set independently rather than assumed.

Summary by CodeRabbit

  • Bug Fixes
    • Notification requests now require a valid access code, even when a username is provided.
    • Viewing your own notifications provides the complete feed, while viewing another account remains restricted.
    • Notification request parameters are safely escaped, preventing malformed queries and path traversal.
    • Full notification access is securely limited to verified self-views.
  • Configuration
    • Added configuration support for the internal notification service token.

Closes half of #90. Two defects in the notifications handler.

Authentication. The guard accepted a bare `user` body field in place of a
code, so the 401 was only reachable when `user` was absent. Supplying it was
enough to be served that account's notifications with no authentication at all.
A valid code is now required before anything else is read.

Path building. username, filter, since and limit were interpolated into the
upstream path through Template(), which emulates JS string coercion and does no
URL encoding. A value carrying / ? or # was therefore re-parsed as URL
structure once the string became a Uri, reaching a different upstream endpoint
with this service's credentials attached. That is what left the api-proxy
per-path allowlist acting as a security control rather than routing hygiene.

NotificationsPath() now escapes every segment and rejects dot segments, exactly
as PostTipsPath() already does for the tips handlers, and for the same reason.
Escaping is a no-op for real values: account names, filter names, notification
ids and integer limits are all unreserved characters, so live requests are
byte-identical.

NOT changed, deliberately: a caller with a valid code can still name another
account via `user`. Decks depends on it. Its notifications column is built from
a free-text account search box and passes settings.username alongside the
signed-in user's code (vision-web deck-notifications-column.tsx), so removing
the override would break a shipped feature. This narrows the exposure from
anyone on the internet to any signed-in user; whether that should be narrowed
further is a product decision tracked in #90.

Tests mirror PostTipsPathTests: real requests unchanged, structural characters
cannot escape their segment, dot segments rejected, and query values cannot
append parameters of their own.

No dotnet SDK on the machine this was written on, so the build and tests were
not run locally. CI runs both on pull_request.
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Secure notification authentication and upstream path construction

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Require valid notification codes before honoring account overrides.
• Escape upstream path and query values, rejecting unsafe dot segments.
• Add regression coverage for path isolation and paging URL compatibility.
Diagram

sequenceDiagram
    actor Client
    participant Handler as Notifications Handler
    participant Auth as Code Validator
    participant Builder as Path Builder
    participant API as Upstream API
    Client->>Handler: POST request
    Handler->>Auth: Validate code
    Auth-->>Handler: Username or invalid
    alt Invalid code
        Handler-->>Client: 401 Unauthorized
    else Valid code
        Handler->>Builder: Escape path values
        alt Dot segment
            Builder-->>Handler: Invalid path
            Handler-->>Client: 400 Bad Request
        else Safe path
            Builder-->>Handler: Escaped path
            Handler->>API: Authenticated GET
            API-->>Handler: Notifications
            Handler-->>Client: Proxied response
        end
    end
Loading
High-Level Assessment

The localized NotificationsPath helper is the best approach for this security fix: it follows the established PostTipsPath pattern, preserves valid request URLs, and avoids a broader URI-building refactor. A generic shared path builder was considered but would expand scope and regression risk without materially improving this focused change.

Files changed (2) +158 / -25

Bug fix (1) +61 / -25
PrivateApi.UserData1.csRequire authentication and safely construct notification paths +61/-25

Require authentication and safely construct notification paths

• Requires a valid code before accepting the optional account override, closing unauthenticated notification access. Introduces NotificationsPath to escape all caller-controlled path and query values, reject dot segments with a 400 response, and safely construct the upstream request.

dotnet/EcencyApi/Handlers/PrivateApi.UserData1.cs

Tests (1) +97 / -0
NotificationsPathTests.csAdd notification path security regression tests +97/-0

Add notification path security regression tests

• Adds coverage proving valid notification URLs remain unchanged while structural path characters and query injection attempts are escaped. Tests also verify dot-segment rejection and preservation of existing paging query delimiters.

dotnet/EcencyApi.Tests/NotificationsPathTests.cs

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 1, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (1) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Notifications authorization remains untested 📎 Requirement gap ☼ Reliability
Description
The added regression suite exercises only URI construction and does not verify unauthorized requests
or mismatched authenticated identities. The authorization defects can therefore recur without the
required automated test failure.
Code

dotnet/EcencyApi.Tests/NotificationsPathTests.cs[R14-17]

+public class NotificationsPathTests
+{
+    [Fact]
+    public void RealRequestsAreUnchanged()
Evidence
Rule 4 requires tests for both unauthenticated body-based selection and authenticated identity
mismatches. Every added test invokes NotificationsPath; none invokes or validates the
authorization behavior of Notifications.

Add regression coverage for notification authorization
dotnet/EcencyApi.Tests/NotificationsPathTests.cs[14-97]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new tests cover notification path escaping but omit the required authorization regressions for body-only and mismatched account access.
## Issue Context
Add handler-level tests proving that missing or invalid codes return 401 even when `user` is supplied, and that a body account differing from the validated account is rejected or cannot select another account's notifications.
## Fix Focus Areas
- dotnet/EcencyApi.Tests/NotificationsPathTests.cs[14-97]
- dotnet/EcencyApi/Handlers/PrivateApi.UserData1.cs[55-78]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can describe a rule in plain language on the Rules page and Qodo drafts it for you

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +14 to +17
public class NotificationsPathTests
{
[Fact]
public void RealRequestsAreUnchanged()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. notifications authorization remains untested 📎 Requirement gap ☼ Reliability

The added regression suite exercises only URI construction and does not verify unauthorized requests
or mismatched authenticated identities. The authorization defects can therefore recur without the
required automated test failure.
Agent Prompt
## Issue description
The new tests cover notification path escaping but omit the required authorization regressions for body-only and mismatched account access.

## Issue Context
Add handler-level tests proving that missing or invalid codes return 401 even when `user` is supplied, and that a body account differing from the validated account is rejected or cannot select another account's notifications.

## Fix Focus Areas
- dotnet/EcencyApi.Tests/NotificationsPathTests.cs[14-97]
- dotnet/EcencyApi/Handlers/PrivateApi.UserData1.cs[55-78]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fair, and fixed. The suite did only cover URI construction, so both original defects could have recurred silently.

ResolveNotificationsTarget() now holds the authorization decision as a pure function, with NotificationsAuthorizationTests covering it directly:

  • no valid code is unauthorized even when an account is named, which is the exact bypass that existed
  • a valid code alone serves that account's complete feed
  • naming your own account is still a self view, case-insensitively
  • naming another account is permitted but never sets full scope, including for near-miss names like good-karm or good_karma

IsTruthy stays in the handler so the port keeps its JS truthiness parity while the decision itself stays pure.

CI: 214 passed, 0 failed.

Follow-up to the auth fix in this PR, and the product half of #90.

Decks builds a notifications column for an arbitrary account and passes that
name alongside the signed-in user's own code. That stays supported: notifications
are largely public data and the column exists for that reason.

But the feed also carries Ecency-only activity that is not public. Favorites and
bookmarks reveal who a user follows and what they saved, and Points transfers,
streaks and the monthly/weekly aggregates exist nowhere on chain. So a request
for SOMEONE ELSE's notifications now carries scope=public, which enotify
restricts to chain-derived types (ecency/enotify-py#21).

This service is the only layer that can make that call, because it is the only
one that has validated who is asking. The comparison is case-insensitive, and a
request for your own account is unaffected: without the flag the upstream path is
byte-identical to what it was before scope existed.

scope is derived from the validated code and never read from the body, so a
caller cannot ask for a wider view than they are entitled to. NotificationsPath
now assembles query values through a list, which keeps the original `?` then `&`
ordering while making the appended parameter unambiguous.

Tests cover the flag off and on, joining against existing since/limit values, and
that a since value trying to smuggle its own scope parameter is escaped into a
literal rather than overriding the real one.

Still no dotnet SDK locally, so CI remains the gate.
@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 63280b78-a6f2-4ae1-8280-7130891b43a8

📥 Commits

Reviewing files that changed from the base of the PR and between da4c67d and 1420aec.

📒 Files selected for processing (4)
  • dotnet/EcencyApi.Tests/NotificationsAuthorizationTests.cs
  • dotnet/EcencyApi.Tests/NotificationsPathTests.cs
  • dotnet/EcencyApi/Config.cs
  • dotnet/EcencyApi/Handlers/PrivateApi.UserData1.cs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The notifications handler now requires a valid code, resolves self and cross-account targets, escapes URL values, rejects dot segments, and uses scope=full with an optional internal token for self views. Tests cover authorization and URL construction.

Changes

Notifications authentication and scope

Layer / File(s) Summary
Notifications target resolution and configuration
dotnet/EcencyApi/Handlers/PrivateApi.UserData1.cs, dotnet/EcencyApi/Config.cs, dotnet/EcencyApi.Tests/NotificationsAuthorizationTests.cs
The handler requires a validated code. Self views receive full scope, while other accounts receive restricted scope. Configuration reads ENOTIFY_INTERNAL_TOKEN. Tests cover authorization and target resolution.
Notifications URL construction and validation
dotnet/EcencyApi/Handlers/PrivateApi.UserData1.cs, dotnet/EcencyApi.Tests/NotificationsPathTests.cs
NotificationsPath escapes caller-supplied values, rejects . and .., preserves paging query shapes, and appends scope=full only when requested.
Upstream request integration
dotnet/EcencyApi/Handlers/PrivateApi.UserData1.cs
The handler sends X-Ecency-Internal-Token for full-scope requests when the configured token is non-empty.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: ⚪ Minimal · up to 1420a

The PR tightens notification authorization and safely escapes upstream path values without any actionable merge-blocking risk remaining beyond normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Notifications
  participant NotificationsPath
  participant UpstreamNotificationsApi
  Client->>Notifications: Submit notifications request
  Notifications->>Notifications: Validate code and resolve target
  Notifications->>NotificationsPath: Build escaped URL with full-scope flag
  NotificationsPath-->>Notifications: Return URL or null
  Notifications->>UpstreamNotificationsApi: Request URL with optional internal token
  UpstreamNotificationsApi-->>Notifications: Return notifications response
  Notifications-->>Client: Return response or authorization error
Loading

Poem

A rabbit checks the code,
Safe paths carry each request,
Full scope marks self views,
Tokens guard the upstream gate,
Tests hop through every case.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.75% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 4 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the two primary changes: requiring a valid notification code and escaping the upstream path.
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.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/notifications-auth-and-path-escaping

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.

ecency added 2 commits September 1, 2026 16:26
Follows the enotify review. enotify now defaults to chain-derived activity only
and requires scope=full to widen, because it performs no authentication of its
own and its host was reachable from the public internet, so an opt-in restriction
protected nothing against a caller who simply omitted the parameter.

This side flips to match. A self-view asks for scope=full; a cross-account view
sends no scope parameter at all, which is the safe direction: any request that
never reaches this handler now gets the restricted feed rather than the whole
one.

Behaviour for users is unchanged in both directions. Deploy this before the
enotify change: scope=full is ignored until enotify ships, so there is no window
where a user loses their own favorites, bookmarks or aggregates.

The forgery test now asserts the opposite direction too: a since value carrying
`scope=full` is escaped into a literal and, with the flag off, no real scope
parameter is appended for it to piggyback on.
Two review findings.

enotify now requires X-Ecency-Internal-Token alongside scope=full, because a
query parameter cannot gate private data on a service with no authentication of
its own. This side presents it, from ENOTIFY_INTERNAL_TOKEN, only on a self-view.
enotify fails closed, so a missing or wrong token costs that user their own
private activity rather than exposing anyone else's.

Qodo was right that the authorization itself had no test: the suite covered URI
construction only, so both original defects could recur silently.
ResolveNotificationsTarget() now holds the decision as a pure function and is
tested directly: no valid code is unauthorized even when an account is named,
which is the exact bypass; a code alone serves that account's complete feed; the
self-view comparison is case-insensitive; naming another account is still
permitted but never sets full scope, including for near-miss names.

IsTruthy stays in the handler so the port keeps its JS truthiness parity while
the decision stays pure.

Still no dotnet SDK locally, so CI remains the gate.
@feruzm
feruzm merged commit 39ee432 into main Sep 1, 2026
4 checks passed
@feruzm
feruzm deleted the fix/notifications-auth-and-path-escaping branch September 1, 2026 18:30
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