-
Notifications
You must be signed in to change notification settings - Fork 2
fix(notifications): require a valid code, and escape the upstream path #91
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
c232454
fix(notifications): require a valid code, and escape the upstream path
da4c67d
Downgrade a cross-account notifications view to scope=public
f30cef0
Invert the flag: ask for scope=full on a self-view, not scope=public
1420aec
Present the enotify secret, and make the authorization decision testable
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| using EcencyApi.Handlers; | ||
| using Xunit; | ||
|
|
||
| namespace EcencyApi.Tests; | ||
|
|
||
| /// <summary> | ||
| /// The authorization decision for /private-api/notifications, which had two defects: | ||
| /// a request could satisfy the guard with a `user` field and no valid code at all, and a | ||
| /// valid code was then overridden by that field anyway. | ||
| /// | ||
| /// Kept separate from path construction because these are the rules that decide whose | ||
| /// data is served, and a regression here is silent rather than a visible break. | ||
| /// </summary> | ||
| public class NotificationsAuthorizationTests | ||
| { | ||
| [Theory] | ||
| [InlineData(null)] | ||
| [InlineData("")] | ||
| public void WithoutAValidCodeTheRequestIsUnauthorized(string? validated) | ||
| { | ||
| // No code at all. | ||
| var (username, fullScope) = PrivateApi.ResolveNotificationsTarget(validated, null); | ||
| Assert.Null(username); | ||
| Assert.False(fullScope); | ||
|
|
||
| // THE BYPASS: naming an account used to be accepted in place of a code. | ||
| var named = PrivateApi.ResolveNotificationsTarget(validated, "victim"); | ||
| Assert.Null(named.Username); | ||
| Assert.False(named.FullScope); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void AValidCodeAloneServesThatAccountsCompleteFeed() | ||
| { | ||
| var (username, fullScope) = PrivateApi.ResolveNotificationsTarget("good-karma", null); | ||
|
|
||
| Assert.Equal("good-karma", username); | ||
| Assert.True(fullScope); | ||
| } | ||
|
|
||
| [Theory] | ||
| [InlineData("good-karma")] | ||
| // Hive names are lowercase, but the comparison must not hinge on that. | ||
| [InlineData("Good-Karma")] | ||
| [InlineData("GOOD-KARMA")] | ||
| public void NamingYourOwnAccountIsStillASelfView(string requested) | ||
| { | ||
| var (username, fullScope) = PrivateApi.ResolveNotificationsTarget("good-karma", requested); | ||
|
|
||
| Assert.Equal(requested, username); | ||
| Assert.True(fullScope); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void NamingAnotherAccountIsServedTheRestrictedFeed() | ||
| { | ||
| // Still permitted: Decks builds notification columns for arbitrary accounts and | ||
| // notifications are largely public. It just does not unlock the complete feed. | ||
| var (username, fullScope) = PrivateApi.ResolveNotificationsTarget("good-karma", "someone-else"); | ||
|
|
||
| Assert.Equal("someone-else", username); | ||
| Assert.False(fullScope); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void OnlyASelfViewEverSetsFullScope() | ||
| { | ||
| // The property that matters: for any requested account other than the validated | ||
| // one, fullScope is false. A near-miss must not slip through. | ||
| foreach (var other in new[] { "good-karm", "good-karma2", "ood-karma", " good-karma", "good_karma" }) | ||
| { | ||
| Assert.False( | ||
| PrivateApi.ResolveNotificationsTarget("good-karma", other).FullScope, | ||
| other); | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| using EcencyApi.Handlers; | ||
| using Xunit; | ||
|
|
||
| namespace EcencyApi.Tests; | ||
|
|
||
| /// <summary> | ||
| /// The notifications handler builds an upstream path by interpolating four | ||
| /// caller-supplied body values: the account name, the filter, a paging cursor and a | ||
| /// limit. Body values are arbitrary strings, so anything structural left unescaped is | ||
| /// re-parsed when the string becomes a Uri — and the upstream call carries this | ||
| /// service's credentials, so a redirected path is a real problem. Same reasoning as | ||
| /// PostTipsPathTests. | ||
| /// </summary> | ||
| public class NotificationsPathTests | ||
| { | ||
| [Fact] | ||
| public void RealRequestsAreUnchanged() | ||
| { | ||
| // Hive names, filter names, notification ids and integer limits are all | ||
| // unreserved characters; escaping must be a no-op for them or this would | ||
| // change every live request. | ||
| Assert.Equal( | ||
| "activities/good-karma", | ||
| PrivateApi.NotificationsPath("good-karma", null, null, null, false)); | ||
| Assert.Equal( | ||
| "follows/good-karma", | ||
| PrivateApi.NotificationsPath("good-karma", "follows", null, null, false)); | ||
| Assert.Equal( | ||
| "activities/user.name?since=f-179530372", | ||
| PrivateApi.NotificationsPath("user.name", null, "f-179530372", null, false)); | ||
| Assert.Equal( | ||
| "follows/good-karma?since=f-179530372&limit=50", | ||
| PrivateApi.NotificationsPath("good-karma", "follows", "f-179530372", "50", false)); | ||
| Assert.Equal( | ||
| "activities/good-karma?limit=50", | ||
| PrivateApi.NotificationsPath("good-karma", null, null, "50", false)); | ||
| } | ||
|
|
||
| [Theory] | ||
| // A slash would add path segments and address a different resource. This is the | ||
| // shape that made the nginx per-path allowlist load-bearing rather than routing | ||
| // hygiene: `unread-count?x=` as a username reached a different upstream endpoint. | ||
| [InlineData("a/b", null)] | ||
| [InlineData("a", "b/c")] | ||
| // A question mark would truncate the path and turn the rest into a query. | ||
| [InlineData("a?x=1", null)] | ||
| [InlineData("a", "b?x=1")] | ||
| // A hash would truncate the path at a fragment. | ||
| [InlineData("a#f", null)] | ||
| [InlineData("a", "b#f")] | ||
| public void StructuralCharactersCannotEscapeTheirSegment(string username, string? filter) | ||
| { | ||
| var path = PrivateApi.NotificationsPath(username, filter, null, null, false); | ||
|
|
||
| Assert.NotNull(path); | ||
| Assert.DoesNotContain("?x=1", path); | ||
| Assert.DoesNotContain("#f", path); | ||
| // The only separators left are the ones this builder wrote itself. | ||
| Assert.Equal(1, path!.Split('/').Length - 1); | ||
| } | ||
|
|
||
| [Theory] | ||
| // Dot segments cannot be fixed by escaping: Uri decodes %2E back to `.` before it | ||
| // removes dot segments, so they have to be rejected outright. | ||
| [InlineData(".", null)] | ||
| [InlineData("..", null)] | ||
| [InlineData("a", ".")] | ||
| [InlineData("a", "..")] | ||
| public void DotSegmentsAreRejected(string username, string? filter) | ||
| { | ||
| Assert.Null(PrivateApi.NotificationsPath(username, filter, null, null, false)); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void QueryValuesCannotAddParameters() | ||
| { | ||
| // A cursor or limit carrying `&` would otherwise append parameters of its own. | ||
| var path = PrivateApi.NotificationsPath("good-karma", null, "a&limit=999", null, false); | ||
| Assert.Equal("activities/good-karma?since=a%26limit%3D999", path); | ||
|
|
||
| var withLimit = PrivateApi.NotificationsPath("good-karma", null, null, "1&x=2", false); | ||
| Assert.Equal("activities/good-karma?limit=1%26x%3D2", withLimit); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void LimitJoinsWithAmpersandOnlyWhenSinceIsPresent() | ||
| { | ||
| // Preserves the original branching: limit rides `&` when since is present and | ||
| // `?` when it is not, so an existing client's paging URLs do not change shape. | ||
| Assert.Equal( | ||
| "activities/x?since=s&limit=10", | ||
| PrivateApi.NotificationsPath("x", null, "s", "10", false)); | ||
| Assert.Equal( | ||
| "activities/x?limit=10", | ||
| PrivateApi.NotificationsPath("x", null, null, "10", false)); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void FullScopeIsAppendedOnlyForASelfView() | ||
| { | ||
| // Omitting the flag is the SAFE direction: enotify defaults to chain-derived | ||
| // activity only, so a cross-account view needs no parameter at all. | ||
| Assert.Equal( | ||
| "activities/good-karma", | ||
| PrivateApi.NotificationsPath("good-karma", null, null, null, false)); | ||
|
|
||
| Assert.Equal( | ||
| "activities/good-karma?scope=full", | ||
| PrivateApi.NotificationsPath("good-karma", null, null, null, true)); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void FullScopeJoinsCorrectlyWithExistingQueryValues() | ||
| { | ||
| Assert.Equal( | ||
| "follows/good-karma?since=f-179530372&limit=50&scope=full", | ||
| PrivateApi.NotificationsPath("good-karma", "follows", "f-179530372", "50", true)); | ||
|
|
||
| Assert.Equal( | ||
| "activities/good-karma?limit=50&scope=full", | ||
| PrivateApi.NotificationsPath("good-karma", null, null, "50", true)); | ||
|
|
||
| Assert.Equal( | ||
| "activities/good-karma?since=s&scope=full", | ||
| PrivateApi.NotificationsPath("good-karma", null, "s", null, true)); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ACallerCannotForgeTheScopeParameter() | ||
| { | ||
| // scope is decided by the handler from the validated code, never read from the | ||
| // body. A value trying to smuggle its own parameter is escaped into a literal, | ||
| // and the real one is appended last regardless. | ||
| var path = PrivateApi.NotificationsPath("good-karma", null, "s&scope=full", null, false); | ||
|
|
||
| Assert.Equal("activities/good-karma?since=s%26scope%3Dfull", path); | ||
| Assert.DoesNotContain("&scope=full", path); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1. notifications authorization remains untested
📎 Requirement gap☼ ReliabilityAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation toolsThere was a problem hiding this comment.
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, withNotificationsAuthorizationTestscovering it directly:good-karmorgood_karmaIsTruthystays in the handler so the port keeps its JS truthiness parity while the decision itself stays pure.CI: 214 passed, 0 failed.