Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/every-colts-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@knocklabs/client": patch
---

[guides] support search param in guide activation url patterns
5 changes: 4 additions & 1 deletion packages/client/src/clients/guide/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,7 +975,10 @@ export class KnockGuideClient {
remoteGuide.activation_url_patterns.map((rule) => {
return {
...rule,
pattern: new URLPattern({ pathname: rule.pathname }),
pattern: new URLPattern({
pathname: rule.pathname ?? undefined,
search: rule.search ?? undefined,
}),
Copy link

Choose a reason for hiding this comment

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

URLPattern matches all URLs when both fields undefined

Medium Severity

When both pathname and search are undefined or null in a GuideActivationUrlPatternData, the URLPattern constructor receives { pathname: undefined, search: undefined }, which defaults all components to wildcard patterns and matches every URL. The type's comment states "At least one part should be present" but this invariant isn't enforced in code. If the server sends malformed data, an "allow" directive would show guides on all pages, or a "block" directive would hide guides everywhere.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is fine, it is validated by the control backend to have at least one part.

};
});

Expand Down
4 changes: 3 additions & 1 deletion packages/client/src/clients/guide/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ export interface GuideActivationUrlRuleData {

interface GuideActivationUrlPatternData {
directive: "allow" | "block";
pathname: string;
// At least one part should be present.
pathname?: string;
search?: string;
}

export interface GuideData<TContent = Any> {
Expand Down
138 changes: 138 additions & 0 deletions packages/client/test/clients/guide/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,4 +540,142 @@ describe("predicateUrlPatterns", () => {
expect(predicateUrlPatterns(differentUrl, patterns)).toBe(true);
});
});

describe("with search patterns", () => {
test("returns true when URL matches an allow pattern with search params", () => {
const patterns: KnockGuideActivationUrlPattern[] = [
{
directive: "allow",
pattern: new URLPattern({ pathname: "/dashboard", search: "tab=settings" }),
},
];

const matchingUrl = new URL("https://example.com/dashboard?tab=settings");
const nonMatchingUrl = new URL("https://example.com/dashboard?tab=overview");

expect(predicateUrlPatterns(matchingUrl, patterns)).toBe(true);
expect(predicateUrlPatterns(nonMatchingUrl, patterns)).toBe(undefined);
});

test("returns false when URL matches a block pattern with search params", () => {
const patterns: KnockGuideActivationUrlPattern[] = [
{
directive: "block",
pattern: new URLPattern({ pathname: "/admin", search: "mode=debug" }),
},
];

const matchingUrl = new URL("https://example.com/admin?mode=debug");
const nonMatchingUrl = new URL("https://example.com/admin?mode=normal");

expect(predicateUrlPatterns(matchingUrl, patterns)).toBe(false);
expect(predicateUrlPatterns(nonMatchingUrl, patterns)).toBe(true);
});

test("handles wildcard patterns in search params", () => {
const patterns: KnockGuideActivationUrlPattern[] = [
{
directive: "allow",
pattern: new URLPattern({ pathname: "/page", search: "id=*" }),
},
];

const matchingUrl = new URL("https://example.com/page?id=123");
const anotherMatchingUrl = new URL("https://example.com/page?id=abc");
const nonMatchingUrl = new URL("https://example.com/page?other=value");

expect(predicateUrlPatterns(matchingUrl, patterns)).toBe(true);
expect(predicateUrlPatterns(anotherMatchingUrl, patterns)).toBe(true);
expect(predicateUrlPatterns(nonMatchingUrl, patterns)).toBe(undefined);
});

test("matches when pathname matches but no search pattern specified", () => {
const patterns: KnockGuideActivationUrlPattern[] = [
{
directive: "allow",
pattern: new URLPattern({ pathname: "/dashboard" }),
},
];

// Should match regardless of search params when no search pattern specified
const urlWithSearch = new URL("https://example.com/dashboard?tab=settings");
const urlWithoutSearch = new URL("https://example.com/dashboard");

expect(predicateUrlPatterns(urlWithSearch, patterns)).toBe(true);
expect(predicateUrlPatterns(urlWithoutSearch, patterns)).toBe(true);
});

test("block pattern with search takes precedence over allow pattern without search", () => {
const patterns: KnockGuideActivationUrlPattern[] = [
{
directive: "allow",
pattern: new URLPattern({ pathname: "/settings/*" }),
},
{
directive: "block",
pattern: new URLPattern({ pathname: "/settings/admin", search: "dangerous=true" }),
},
];

const blockedUrl = new URL("https://example.com/settings/admin?dangerous=true");
const allowedUrl = new URL("https://example.com/settings/admin?dangerous=false");

expect(predicateUrlPatterns(blockedUrl, patterns)).toBe(false);
expect(predicateUrlPatterns(allowedUrl, patterns)).toBe(true);
});

test("handles multiple search params in pattern", () => {
const patterns: KnockGuideActivationUrlPattern[] = [
{
directive: "allow",
pattern: new URLPattern({ pathname: "/report", search: "type=sales&year=2024" }),
},
];

const matchingUrl = new URL("https://example.com/report?type=sales&year=2024");
const partialMatchUrl = new URL("https://example.com/report?type=sales");
const wrongOrderUrl = new URL("https://example.com/report?year=2024&type=sales");

expect(predicateUrlPatterns(matchingUrl, patterns)).toBe(true);
expect(predicateUrlPatterns(partialMatchUrl, patterns)).toBe(undefined);
// URLPattern is sensitive to search param order
expect(predicateUrlPatterns(wrongOrderUrl, patterns)).toBe(undefined);
});

test("handles multiple search params in pattern, to match a single search param regardless of the order", () => {
const patterns: KnockGuideActivationUrlPattern[] = [
{
directive: "allow",
pattern: new URLPattern({ pathname: "/report", search: "*role=admin*" }),
},
];

const url1 = new URL("https://example.com/report?role=admin");
const url2 = new URL("https://example.com/report?year=2022&role=admin");
const url3 = new URL("https://example.com/report?role=admin&year=2022");
const url4 = new URL("https://example.com/report?location=nyc&role=admin&year=2022");
const url5 = new URL("https://example.com/report?location=nyc&year=2022");

expect(predicateUrlPatterns(url1, patterns)).toBe(true);
expect(predicateUrlPatterns(url2, patterns)).toBe(true);
expect(predicateUrlPatterns(url3, patterns)).toBe(true);
expect(predicateUrlPatterns(url4, patterns)).toBe(true);
expect(predicateUrlPatterns(url5, patterns)).toBe(undefined);
});

test("handles search pattern with wildcard for any search params", () => {
const patterns: KnockGuideActivationUrlPattern[] = [
{
directive: "block",
pattern: new URLPattern({ pathname: "/api", search: "*" }),
},
];

const urlWithSearch = new URL("https://example.com/api?key=value");
const urlWithoutSearch = new URL("https://example.com/api");

expect(predicateUrlPatterns(urlWithSearch, patterns)).toBe(false);
expect(predicateUrlPatterns(urlWithoutSearch, patterns)).toBe(false);
});
});
});
Loading