Skip to content

Duo URL redirect enhancements #14640

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 3 commits into from
May 12, 2025
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
46 changes: 43 additions & 3 deletions apps/web/src/connectors/duo-redirect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ describe("duo-redirect", () => {
});

it("should redirect to a valid Duo URL", () => {
const validUrl = "https://api-123.duosecurity.com/auth";
const validUrl = "https://api-123.duosecurity.com/oauth/v1/authorize";
redirectToDuoFrameless(validUrl);
expect(window.location.href).toBe(validUrl);
});

it("should redirect to a valid Duo Federal URL", () => {
const validUrl = "https://api-123.duofederal.com/auth";
const validUrl = "https://api-123.duofederal.com/oauth/v1/authorize";
redirectToDuoFrameless(validUrl);
expect(window.location.href).toBe(validUrl);
});
Expand All @@ -27,15 +27,55 @@ describe("duo-redirect", () => {
});

it("should throw an error for an malicious URL with valid redirect embedded", () => {
const invalidUrl = "https://malicious-site.com\\@api-123.duosecurity.com/auth";
const invalidUrl = "https://malicious-site.com\\@api-123.duosecurity.com/oauth/v1/authorize";
expect(() => redirectToDuoFrameless(invalidUrl)).toThrow("Invalid redirect URL");
});

it("should throw an error for a URL with a malicious subdomain", () => {
const maliciousSubdomainUrl =
"https://api-a86d5bde.duosecurity.com.evil.com/oauth/v1/authorize";
expect(() => redirectToDuoFrameless(maliciousSubdomainUrl)).toThrow("Invalid redirect URL");
});

it("should throw an error for a URL using HTTP protocol", () => {
const maliciousSubdomainUrl = "http://api-a86d5bde.duosecurity.com/oauth/v1/authorize";
expect(() => redirectToDuoFrameless(maliciousSubdomainUrl)).toThrow(
"Invalid redirect URL: invalid protocol",
);
});

it("should throw an error for a URL with javascript code", () => {
const maliciousSubdomainUrl = "javascript://https://api-a86d5bde.duosecurity.com%0Aalert(1)";
expect(() => redirectToDuoFrameless(maliciousSubdomainUrl)).toThrow(
"Invalid redirect URL: invalid protocol",
);
});

it("should throw an error for a non-HTTPS URL", () => {
const nonHttpsUrl = "http://api-123.duosecurity.com/auth";
expect(() => redirectToDuoFrameless(nonHttpsUrl)).toThrow("Invalid redirect URL");
});

it("should throw an error for a URL with invalid port specified", () => {
const urlWithPort = "https://api-123.duyosecurity.com:8080/auth";
expect(() => redirectToDuoFrameless(urlWithPort)).toThrow(
"Invalid redirect URL: port not allowed",
);
});

it("should redirect to a valid Duo Federal URL with valid port", () => {
const validUrl = "https://api-123.duofederal.com:443/oauth/v1/authorize";
redirectToDuoFrameless(validUrl);
expect(window.location.href).toBe(validUrl);
});

it("should throw an error for a URL with an invalid pathname", () => {
const urlWithPort = "https://api-123.duyosecurity.com/../evil/path/here/";
expect(() => redirectToDuoFrameless(urlWithPort)).toThrow(
"Invalid redirect URL: invalid pathname",
);
});

it("should throw an error for a URL with an invalid hostname", () => {
const invalidHostnameUrl = "https://api-123.invalid.com";
expect(() => redirectToDuoFrameless(invalidHostnameUrl)).toThrow("Invalid redirect URL");
Expand Down
39 changes: 28 additions & 11 deletions apps/web/src/connectors/duo-redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,29 +57,46 @@ window.addEventListener("load", async () => {
* @param redirectUrl the duo auth url
*/
export function redirectToDuoFrameless(redirectUrl: string) {
// Regex to match a valid duo redirect URL
// Validation for Duo redirect URL to prevent open redirect or XSS vulnerabilities
// Only used for Duo 2FA redirects in the extension
/**
* This regex checks for the following:
* The string must start with "https://api-"
* Followed by a subdomain that can contain letters, numbers
* The hostname must start with a subdomain that begins with "api-" followed by a
* string that can contain letters or numbers of indeterminate length
* Followed by either "duosecurity.com" or "duofederal.com"
* This ensures that the redirect does not contain any malicious content
* and is a valid Duo URL.
* */
const duoRedirectUrlRegex = /^https:\/\/api-[a-zA-Z0-9]+\.(duosecurity|duofederal)\.com/;
// Check if the redirect URL matches the regex
if (!duoRedirectUrlRegex.test(redirectUrl)) {
throw new Error("Invalid redirect URL");
}
// At this point we know the URL to be valid, but we need to check for embedded credentials
const duoRedirectUrlRegex = /^api-[a-zA-Z0-9]+\.(duosecurity|duofederal)\.com$/;
const validateUrl = new URL(redirectUrl);
// URLs should not contain

// Check that no embedded credentials are present
if (validateUrl.username || validateUrl.password) {
throw new Error("Invalid redirect URL: embedded credentials not allowed");
}

window.location.href = decodeURIComponent(redirectUrl);
// Check that the protocol is HTTPS
if (validateUrl.protocol !== "https:") {
throw new Error("Invalid redirect URL: invalid protocol");
}

// Check that the port is not specified
if (validateUrl.port && validateUrl.port !== "443") {
throw new Error("Invalid redirect URL: port not allowed");
}

if (validateUrl.pathname !== "/oauth/v1/authorize") {
throw new Error("Invalid redirect URL: invalid pathname");
}

// Check if the redirect hostname matches the regex
// Only check the hostname part of the URL to avoid over-zealous Regex expressions from matching
// and causing an Open Redirect vulnerability. Always use hostname instead of host, because host includes port if specified.
if (!duoRedirectUrlRegex.test(validateUrl.hostname)) {
throw new Error("Invalid redirect URL");
}

window.location.href = redirectUrl;
}

/**
Expand Down
Loading