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 packages/core/src/@types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ export type AuthInternalErrorCode =
| "COOKIE_PARSING_FAILED"
| "COOKIE_NOT_FOUND"
| "INVALID_ENVIRONMENT_CONFIGURATION"
| "INVALID_URL"

export type AuthSecurityErrorCode =
| "INVALID_STATE"
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/actions/callback/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { z } from "zod"
import { createEndpoint, createEndpointConfig, HeadersBuilder } from "@aura-stack/router"
import { createCSRF } from "@/secure.js"
import { cacheControl } from "@/headers.js"
import { isRelativeURL } from "@/assert.js"
import { getUserInfo } from "@/actions/callback/userinfo.js"
import { OAuthAuthorizationErrorResponse } from "@/schemas.js"
import { AuthSecurityError, OAuthProtocolError } from "@/errors.js"
import { equals, isValidRelativePath, sanitizeURL } from "@/utils.js"
import { equals } from "@/utils.js"
import { createAccessToken } from "@/actions/callback/access-token.js"
import { createSessionCookie, getCookie, expiredCookieAttributes } from "@/cookie.js"
import type { JWTPayload } from "@/jose.js"
Expand Down Expand Up @@ -64,8 +65,7 @@ export const callbackAction = (oauth: OAuthProviderRecord) => {
}

const accessToken = await createAccessToken(oauthConfig, cookieRedirectURI, code, codeVerifier)
const sanitized = sanitizeURL(cookieRedirectTo)
if (!isValidRelativePath(sanitized)) {
if (!isRelativeURL(cookieRedirectTo)) {
throw new AuthSecurityError(
"POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED",
"Invalid redirect path. Potential open redirect attack detected."
Expand All @@ -77,7 +77,7 @@ export const callbackAction = (oauth: OAuthProviderRecord) => {
const csrfToken = await createCSRF(jose)

const headers = new HeadersBuilder(cacheControl)
.setHeader("Location", sanitized)
.setHeader("Location", cookieRedirectTo)
.setCookie(cookies.sessionToken.name, sessionCookie, cookies.sessionToken.attributes)
.setCookie(cookies.csrfToken.name, csrfToken, cookies.csrfToken.attributes)
.setCookie(cookies.state.name, "", expiredCookieAttributes)
Expand Down
67 changes: 33 additions & 34 deletions packages/core/src/actions/signIn/authorization.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { isValidURL } from "@/assert.js"
import { isRelativeURL, isSameOrigin, isValidURL } from "@/assert.js"
import { OAuthAuthorization } from "@/schemas.js"
import { AuthInternalError, AuthSecurityError, isAuthSecurityError } from "@/errors.js"
import { equals, formatZodError, getNormalizedOriginPath, sanitizeURL, toCastCase } from "@/utils.js"
import { AuthInternalError } from "@/errors.js"
import { extractPath, formatZodError, toCastCase } from "@/utils.js"
import type { OAuthProviderCredentials } from "@/@types/index.js"

/**
Expand Down Expand Up @@ -35,6 +35,7 @@ export const createAuthorizationURL = (
}

export const getOriginURL = (request: Request, trustedProxyHeaders?: boolean) => {
let origin = new URL(request.url).origin
const headers = request.headers
if (trustedProxyHeaders) {
const protocol = headers.get("X-Forwarded-Proto") ?? headers.get("Forwarded")?.match(/proto=([^;]+)/i)?.[1] ?? "http"
Expand All @@ -43,10 +44,12 @@ export const getOriginURL = (request: Request, trustedProxyHeaders?: boolean) =>
headers.get("Host") ??
headers.get("Forwarded")?.match(/host=([^;]+)/i)?.[1] ??
null
return new URL(`${protocol}://${host}${getNormalizedOriginPath(new URL(request.url).pathname)}`)
} else {
return new URL(getNormalizedOriginPath(request.url))
origin = `${protocol}://${host}`
}
if (!isValidURL(origin)) {
throw new AuthInternalError("INVALID_URL", "The constructed origin URL is invalid.")
}
return origin
}

/**
Expand All @@ -57,61 +60,57 @@ export const getOriginURL = (request: Request, trustedProxyHeaders?: boolean) =>
* @returns The redirect URI for the OAuth callback.
*/
export const createRedirectURI = (request: Request, oauth: string, basePath: string, trustedProxyHeaders?: boolean) => {
const url = getOriginURL(request, trustedProxyHeaders)
return `${url.origin}${basePath}/callback/${oauth}`
const origin = getOriginURL(request, trustedProxyHeaders)
return `${origin}${basePath}/callback/${oauth}`
}

/**
* Verifies if the request's origin matches the expected origin. It accepts the redirectTo search
* parameter for redirection. It checks the 'Referer' header of the request with the origin where
* the authentication flow is hosted. If they do not match, it throws an AuthError to avoid
* the authentication flow is hosted. If they do not match, it returns "/" to avoid
* potential `Open URL Redirection` attacks.
*
* @param request The incoming request object
* @param redirectTo Optional redirectTo parameter to override the referer
* @param trustedProxyHeaders Whether to trust proxy headers for origin determination
* @returns The pathname of the referer URL if origins match
*/
export const createRedirectTo = (request: Request, redirectTo?: string, trustedProxyHeaders?: boolean) => {
try {
const headers = request.headers
const origin = headers.get("Origin")
const referer = headers.get("Referer")
let hostedURL = getOriginURL(request, trustedProxyHeaders)
const trustedOrigin = getOriginURL(request, trustedProxyHeaders)
if (redirectTo) {
if (redirectTo.startsWith("/")) {
return sanitizeURL(redirectTo)
if (isRelativeURL(redirectTo)) {
return redirectTo
}
const redirectToURL = new URL(sanitizeURL(getNormalizedOriginPath(redirectTo)))
if (!isValidURL(redirectTo) || !equals(redirectToURL.origin, hostedURL.origin)) {
throw new AuthSecurityError(
"POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED",
"The redirectTo parameter does not match the hosted origin."
)
if (isValidURL(redirectTo) && isSameOrigin(redirectTo, trustedOrigin)) {
return extractPath(redirectTo)
}
return sanitizeURL(redirectToURL.pathname)
console.warn("[WARNING][OPEN_REDIRECT_ATTACK]: The redirectTo parameter does not match the hosted origin.")
return "/"
}
if (referer) {
const refererURL = new URL(sanitizeURL(referer))
if (!isValidURL(referer) || !equals(refererURL.origin, hostedURL.origin)) {
throw new AuthSecurityError(
"POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED",
"The referer of the request does not match the hosted origin."
)
if (isRelativeURL(referer)) {
return referer
}
if (isValidURL(referer) && isSameOrigin(referer, trustedOrigin)) {
return extractPath(referer)
}
return sanitizeURL(refererURL.pathname)
console.warn("[WARNING][OPEN_REDIRECT_ATTACK]: The referer of the request does not match the hosted origin.")
return "/"
}
if (origin) {
const originURL = new URL(sanitizeURL(getNormalizedOriginPath(origin)))
if (!isValidURL(origin) || !equals(originURL.origin, hostedURL.origin)) {
throw new AuthSecurityError("POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", "Invalid origin (potential CSRF).")
if (isValidURL(origin) && isSameOrigin(origin, trustedOrigin)) {
return extractPath(origin)
}
return sanitizeURL(originURL.pathname)
console.warn("[WARNING][OPEN_REDIRECT_ATTACK]: Invalid origin (potential CSRF).")
return "/"
}
return "/"
} catch (error) {
if (isAuthSecurityError(error)) {
throw error
}
throw new AuthSecurityError("POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", "Invalid origin (potential CSRF).")
console.warn("[WARNING][OPEN_REDIRECT_ATTACK]: Invalid origin (potential CSRF).")
return "/"
}
}
11 changes: 6 additions & 5 deletions packages/core/src/actions/signOut/signOut.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { z } from "zod"
import { createEndpoint, createEndpointConfig, HeadersBuilder, statusCode } from "@aura-stack/router"
import { getBaseURL } from "@/utils.js"
import { verifyCSRF } from "@/secure.js"
import { cacheControl } from "@/headers.js"
import { AuthSecurityError } from "@/errors.js"
import { getNormalizedOriginPath } from "@/utils.js"
import { expiredCookieAttributes } from "@/cookie.js"
import { createRedirectTo } from "@/actions/signIn/authorization.js"

Expand All @@ -27,7 +27,7 @@ export const signOutAction = createEndpoint(
request,
headers,
searchParams: { redirectTo },
context: { jose, cookies },
context: { jose, cookies, trustedProxyHeaders },
} = ctx

const session = headers.getCookie(cookies.sessionToken.name)
Expand All @@ -45,12 +45,13 @@ export const signOutAction = createEndpoint(
await verifyCSRF(jose, csrfToken, header)
await jose.decodeJWT(session)

const normalizedOriginPath = getNormalizedOriginPath(request.url)
const baseURL = getBaseURL(request)
const location = createRedirectTo(
new Request(normalizedOriginPath, {
new Request(baseURL, {
headers: headers.toHeaders(),
}),
redirectTo
redirectTo,
trustedProxyHeaders
)
const headersList = new HeadersBuilder(cacheControl)
.setHeader("Location", location)
Expand Down
59 changes: 56 additions & 3 deletions packages/core/src/assert.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { equals } from "@/utils.js"
import type { JWTPayloadWithToken } from "@/@types/index.js"

export const isFalsy = (value: unknown): boolean => {
Expand All @@ -8,13 +9,65 @@ export const isRequest = (value: unknown): value is Request => {
return typeof Request !== "undefined" && value instanceof Request
}

export const unsafeChars = [
"<",
">",
'"',
"`",
" ",
"\r",
"\n",
"\t",
"\\",
"%2F",
"%5C",
"%2f",
"%5c",
"\r\n",
"%0A",
"%0D",
"%0a",
"%0d",
"..",
"..",
"//",
"///",
"...",
"%20",
"\0",
]

export const isValidURL = (value: string): boolean => {
if (value.includes("\r\n") || value.includes("\n") || value.includes("\r")) return false
if (!new RegExp(/^https?:\/\/[^/]/).test(value)) {
return false
}
const match = value.match(/^(https?:\/\/)(.*)$/)
if (!match) return false
const rest = match[2]
for (const char of unsafeChars) {
if (rest.includes(char)) return false
}
const regex =
/^https?:\/\/(?:[a-zA-Z0-9._-]+|localhost|\[[0-9a-fA-F:]+\])(?::\d{1,5})?(?:\/[a-zA-Z0-9._~!$&'()*+,;=:@-]*)*\/?$/
return regex.test(value)
/^https?:\/\/(?:[a-zA-Z0-9._-]+|localhost|\[[0-9a-fA-F:]+\])(?::\d{1,5})?(?:\/[a-zA-Z0-9._~!$&'()?#*+,;=:@-]*)*\/?$/

return regex.test(match[0])
}

export const isJWTPayloadWithToken = (payload: unknown): payload is JWTPayloadWithToken => {
return typeof payload === "object" && payload !== null && "token" in payload && typeof payload?.token === "string"
}

export const isRelativeURL = (value: string): boolean => {
if (value.length > 100) return false
for (const char of unsafeChars) {
if (value.includes(char)) return false
}
const regex = /^\/[a-zA-Z0-9\-_\/.?&=#]*\/?$/
return regex.test(value)
}

export const isSameOrigin = (origin: string, expected: string): boolean => {
const originURL = new URL(origin)
const expectedURL = new URL(expected)
return equals(originURL.origin, expectedURL.origin)
}
103 changes: 9 additions & 94 deletions packages/core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,83 +32,6 @@ export const equals = (a: string | number | undefined | null, b: string | number
return a === b
}

/**
* Sanitizes a URL by removing dangerous patterns that could be used for path traversal
* or other attacks. This function:
* - Decodes URL-encoded characters
* - Removes multiple consecutive slashes (preserving protocol slashes)
* - Removes path traversal patterns (..)
* - Removes trailing slashes (except root)
* - Trims whitespace
*
* @param url - The URL or path to sanitize
* @returns The sanitized URL or path
*/
export const sanitizeURL = (url: string) => {
try {
let decodedURL = decodeURIComponent(url).trim()
const protocolMatch = decodedURL.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)/)
let protocol = ""
let rest = decodedURL
if (protocolMatch) {
protocol = protocolMatch[1]
rest = decodedURL.slice(protocol.length)
const slashIndex = rest.indexOf("/")
if (slashIndex === -1) {
return protocol + rest
}
const domain = rest.slice(0, slashIndex)
let path = rest
.slice(slashIndex)
.replace(/\/\.\.\//g, "/")
.replace(/\/\.\.$/, "")
.replace(/\.{2,}/g, "")
.replace(/\/{2,}/g, "/")
if (path !== "/" && path.endsWith("/")) {
path = path.replace(/\/+$/, "/")
} else if (path !== "/") {
path = path.replace(/\/+$/, "")
}
return protocol + domain + path
}
let sanitized = decodedURL
.replace(/\/\.\.\//g, "/")
.replace(/\/\.\.$/, "")
.replace(/\.{2,}/g, "")
.replace(/\/{2,}/g, "/")

if (sanitized !== "/" && sanitized.endsWith("/")) {
sanitized = sanitized.replace(/\/+$/, "/")
} else if (sanitized !== "/") {
sanitized = sanitized.replace(/\/+$/, "")
}
return sanitized
} catch {
return url.trim()
}
}

/**
* Validates that a path is a safe relative path to prevent open redirect attacks.
* A safe relative path must:
* - Start with '/'
* - Not contain protocol schemes (://)
* - Not contain newline characters
* - Not contain null bytes
* - Not be an absolute URL
*
* @param path - The path to validate
* @returns true if the path is safe, false otherwise
*/
export const isValidRelativePath = (path: string | undefined | null): boolean => {
if (!path || typeof path !== "string") return false
if (!path.startsWith("/") || path.includes("://") || path.includes("\r") || path.includes("\n")) return false
if (/[\x00-\x1F\x7F]/.test(path) || path.includes("\0")) return false
const sanitized = sanitizeURL(path)
if (sanitized.includes("..")) return false
return true
}

export const onErrorHandler: RouterConfig["onError"] = (error) => {
if (isRouterError(error)) {
const { message, status, statusText } = error
Expand Down Expand Up @@ -143,23 +66,9 @@ export const onErrorHandler: RouterConfig["onError"] = (error) => {
return Response.json({ type: "SERVER_ERROR", code: "server_error", message: "An unexpected error occurred" }, { status: 500 })
}

/**
* Extracts and normalizes the origin and pathname from a URL string.
* Removes query parameters and hash fragments for a clean path.
* Falls back to the original string if URL parsing fails.
*
* @param path - The URL or path string to process
* @returns The normalized URL with origin and pathname, or the original path
*/
export const getNormalizedOriginPath = (path: string): string => {
try {
const url = new URL(path)
url.hash = ""
url.search = ""
return `${url.origin}${url.pathname}`
} catch {
return sanitizeURL(path)
}
export const getBaseURL = (request: Request) => {
const url = new URL(request.url)
return `${url.origin}${url.pathname}`
}

export const toISOString = (date: Date | string | number): string => {
Expand Down Expand Up @@ -189,3 +98,9 @@ export const formatZodError = <T extends Record<string, unknown> = Record<string
}
}, {})
}

export const extractPath = (url: string): string => {
const pathRegex = /^https?:\/\/[a-zA-Z0-9_\-\.]+(:\d+)?(\/.*)$/
const match = url.match(pathRegex)
return match && match[2] ? match[2] : "/"
}
Loading