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
1 change: 1 addition & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
!.yarn/plugins
!.yarn/releases
!.yarn/versions
package-lock.json

# testing
/coverage
Expand Down
80 changes: 80 additions & 0 deletions frontend/lib/__tests__/url-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect } from "vitest"
import { isSafeRedirectUrl } from "../url-validation"

describe("isSafeRedirectUrl", () => {
describe("should return true for safe relative URLs", () => {
it("accepts simple paths", () => {
expect(isSafeRedirectUrl("/dashboard")).toBe(true)
expect(isSafeRedirectUrl("/configure")).toBe(true)
expect(isSafeRedirectUrl("/settings")).toBe(true)
})

it("accepts paths with query parameters", () => {
expect(isSafeRedirectUrl("/dashboard?tab=active")).toBe(true)
expect(isSafeRedirectUrl("/configure?step=2")).toBe(true)
})

it("accepts paths with hash fragments", () => {
expect(isSafeRedirectUrl("/dashboard#section")).toBe(true)
expect(isSafeRedirectUrl("/page#top")).toBe(true)
})

it("accepts nested paths", () => {
expect(isSafeRedirectUrl("/dashboard/deployments/123")).toBe(true)
expect(isSafeRedirectUrl("/a/b/c/d/e")).toBe(true)
})
})

describe("should return false for unsafe URLs", () => {
it("rejects absolute URLs with http protocol", () => {
expect(isSafeRedirectUrl("http://evil.com")).toBe(false)
expect(isSafeRedirectUrl("http://example.com/path")).toBe(false)
})

it("rejects absolute URLs with https protocol", () => {
expect(isSafeRedirectUrl("https://evil.com")).toBe(false)
expect(isSafeRedirectUrl("https://example.com/path")).toBe(false)
})

it("rejects protocol-relative URLs", () => {
expect(isSafeRedirectUrl("//evil.com")).toBe(false)
expect(isSafeRedirectUrl("//example.com/path")).toBe(false)
})

it("rejects javascript: URLs", () => {
expect(isSafeRedirectUrl("javascript:alert(1)")).toBe(false)
expect(isSafeRedirectUrl("javascript:void(0)")).toBe(false)
})

it("rejects data: URLs", () => {
expect(isSafeRedirectUrl("data:text/html,<script>alert(1)</script>")).toBe(false)
})

it("rejects URLs not starting with /", () => {
expect(isSafeRedirectUrl("dashboard")).toBe(false)
expect(isSafeRedirectUrl("./dashboard")).toBe(false)
expect(isSafeRedirectUrl("../dashboard")).toBe(false)
})

it("rejects other malicious protocols", () => {
expect(isSafeRedirectUrl("file:///etc/passwd")).toBe(false)
expect(isSafeRedirectUrl("ftp://server.com/file")).toBe(false)
})
})

describe("edge cases", () => {
it("handles empty strings", () => {
expect(isSafeRedirectUrl("")).toBe(false)
})

it("handles URLs with encoded characters", () => {
expect(isSafeRedirectUrl("/path?url=http%3A%2F%2Fevil.com")).toBe(true) // encoded as query param is OK
expect(isSafeRedirectUrl("/path%2F..%2F..%2Fetc")).toBe(true) // path traversal encoded is still a relative path
})

it("rejects URLs trying to bypass with protocols in path", () => {
// These should still be rejected as they contain ://
expect(isSafeRedirectUrl("/redirect?url=https://evil.com")).toBe(false)
Comment on lines +76 to +77
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

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

This test has an incorrect expectation. The URL /redirect?url=https://evil.com is actually a safe relative path - it redirects to the /redirect page with https://evil.com as a query parameter value. The current validation function correctly identifies this as unsafe due to the includes("://") check, but this is a false positive that would break legitimate use cases.

The test comment states "These should still be rejected as they contain ://", but this reasoning is flawed - the presence of "://" in query parameter values does not make a relative URL unsafe. Update this test to expect true instead of false, or remove it if the validation logic is fixed to properly handle query parameters.

Suggested change
// These should still be rejected as they contain ://
expect(isSafeRedirectUrl("/redirect?url=https://evil.com")).toBe(false)
// A relative URL is safe even if a query parameter contains ://
expect(isSafeRedirectUrl("/redirect?url=https://evil.com")).toBe(true)

Copilot uses AI. Check for mistakes.
})
})
})
Comment on lines +65 to +80
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

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

Test coverage is missing for several important edge cases that could be security vulnerabilities:

  1. Backslash-based bypasses: /\example.com or /\\example.com
  2. Whitespace handling: URLs with leading/trailing whitespace like " /dashboard" or "/dashboard "
  3. URLs with control characters (tabs, newlines): /dashboard\t, /dashboard\n
  4. Mixed case protocol attempts: /HtTpS://evil.com (though this would likely be caught by the current logic)

Add test cases for these scenarios to ensure the validation handles them correctly and doesn't have bypass vulnerabilities.

Copilot uses AI. Check for mistakes.
41 changes: 41 additions & 0 deletions frontend/lib/url-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Validates if a URL is safe for redirecting within the application.
*
* A URL is considered safe if it:
* 1. Is a relative path starting with "/"
* 2. Does not contain protocol markers (like "://") which indicate absolute URLs
* 3. Does not start with "//" (protocol-relative URLs)
*
* This prevents open redirect vulnerabilities where attackers could redirect
* users to malicious external sites.
*
* @param url - The URL string to validate
* @returns true if the URL is safe for internal redirects, false otherwise
*
* @example
* ```ts
* isSafeRedirectUrl("/dashboard") // true
* isSafeRedirectUrl("/dashboard?tab=active") // true
* isSafeRedirectUrl("https://evil.com") // false
* isSafeRedirectUrl("//evil.com") // false
* isSafeRedirectUrl("javascript:alert(1)") // false
* ```
*/
export function isSafeRedirectUrl(url: string): boolean {
// Must start with "/" to be a relative path
if (!url.startsWith("/")) {
return false
}

// Must not start with "//" (protocol-relative URL)
if (url.startsWith("//")) {
return false
}

// Must not contain "://" which indicates an absolute URL with protocol
if (url.includes("://")) {
Comment on lines +35 to +36
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

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

The validation logic incorrectly rejects legitimate relative URLs that contain "://" in their query parameters. For example, /redirect?url=https://evil.com is a safe relative path - the user would be redirected to /redirect with a query parameter, not to https://evil.com. This creates a false positive that would break legitimate use cases where URLs are passed as query parameters.

The validation should only reject the URL if the protocol appears in the path component itself before any query parameters, not in query string values. Consider parsing the URL to check only the pathname, or at minimum, check if "://" appears before the first "?" character.

Suggested change
// Must not contain "://" which indicates an absolute URL with protocol
if (url.includes("://")) {
// Must not contain "://" in the path portion (before any "?"), which indicates an absolute URL with protocol
const queryIndex = url.indexOf("?")
const pathPart = queryIndex === -1 ? url : url.slice(0, queryIndex)
if (pathPart.includes("://")) {

Copilot uses AI. Check for mistakes.
return false
}
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

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

The validation does not check for backslash-based bypass attempts. In some browsers and URL parsing contexts, backslashes can be normalized to forward slashes. For example, URLs like /\example.com or /\\example.com might be interpreted as //example.com in certain contexts, creating a protocol-relative URL bypass.

Consider adding validation to reject URLs containing backslashes, or at minimum, add test coverage for these edge cases to verify the behavior of the new URL() constructor in Next.js middleware with backslash-containing URLs.

Suggested change
}
}
// Must not contain backslashes, which some parsers normalize to "/"
if (url.includes("\\")) {
return false
}

Copilot uses AI. Check for mistakes.

return true
Comment on lines +24 to +40
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

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

The validation does not handle URLs with leading or trailing whitespace, or URLs containing tab/newline characters. While the middleware extracts the callbackUrl from URL search parameters (which typically handles URL encoding), it's a security best practice to explicitly trim and sanitize the input.

Consider adding url.trim() at the beginning of the validation function, and potentially checking for or stripping control characters (tabs, newlines, etc.) that could be used in bypass attempts.

Copilot uses AI. Check for mistakes.
}
8 changes: 8 additions & 0 deletions frontend/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { auth } from "@/auth"
import { NextResponse } from "next/server"
import { isSafeRedirectUrl } from "@/lib/url-validation"

/**
* Middleware to protect routes requiring authentication
Expand Down Expand Up @@ -38,6 +39,13 @@ export default auth((req) => {
// Redirect to intended destination if already signed in and visiting sign-in page
if (pathname === "/signin" && isAuthenticated) {
const callbackUrl = req.nextUrl.searchParams.get("callbackUrl") || "/dashboard"

// Validate callbackUrl to prevent open redirect vulnerability
if (!isSafeRedirectUrl(callbackUrl)) {
console.warn(`[Middleware] Rejected unsafe callbackUrl: ${callbackUrl}`)
return NextResponse.redirect(new URL("/dashboard", req.url))
}

console.log(`[Middleware] Authenticated user on signin page, redirecting to ${callbackUrl}`)
return NextResponse.redirect(new URL(callbackUrl, req.url))
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9",
"eslint-config-next": "16.0.10",
"jsdom": "^27.4.0",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.0.16"
}
}
}
Loading