MasterKale / SimpleWebAuthn

WebAuthn, Simplified. A collection of TypeScript-first libraries for simpler WebAuthn integration. Supports modern browsers, Node, Deno, and more.
https://simplewebauthn.dev
MIT License
1.62k stars 137 forks source link

Allow verifying expected origins with a regex #435

Closed NeoLegends closed 1 year ago

NeoLegends commented 1 year ago

Hi!

I'm using this library on with a Netlify deployment, where each Pull Request and each branch get built separately on a separate subdomain to be able to preview the contents there. The domain is always of the structure deploy-preview-PR_NUMBER--PROJECT_NAME.netlify.app. This dynamic URL structure, however, makes it difficult to use passkeys there.

The reason is expectedOrigin can already take a (static) list of valid origins, but the list of possible deploy preview URLs is unbounded/dynamic. However, as long as the URLs all follow the structure they're valid origins for the project.

Therefore, would it be possible to extend expectedOrigin with regex support or with support for a custom validator function? E.g. for the Netlify case the regex would be something like /^(yourproject\.com|deploy-preview-\d+--yourproject\.netlify\.app)$/.

Adding support for Regex/custom validators also comes with the risk of people doing it wrong, so I could see a tradeoff here, but without that I don't see how this library could work w/ a dynamically changing list of origins.

Best Regards

spendres commented 1 year ago

@NeoLegends

Your remedy wouldn't work because the Relying Party ID is a string and is part of the cryptographic signature being verified – and besides this would allow a regex of /.*/ to match any origin. No passkey implementation would work with an unknown origin. You can read the assigned deploy-preview url from the host and pass that as the expected origin. But, every time the url changed, your users would have to re-register as there would be no matching url in the passkeys store in their google/apple/microsoft keychain. This is a feature that helps prevent man-in-the-middle attacks.

One way to deal with this constraint is to use subdomains for your environments... like: https://localhost:3000 https://dev.yourdomain.com https://test.yourdomain.com https://stage.yourdomain.com https://www.yourdomain.com

Users in each subdomain will have to register to create an account in that environment, but they will be prompted to login using the passkey that matches that environment. For example, the first time a user uses the dev subdomain, they would create a passkey and could call it MyDevPK. The next time they use that url and want to login, their browser would list their already registered MyDevPK passkey for them re-use... or they could create a new one. They would also create a MyTestPK, a MyStagePK, and MyLivePK for the production url. If you don't control the domain, you are better off testing using localhost and a reverse proxy.

Another way is to use NGROK as a reverse proxy to your environments. This way the NGROK assigned URL would not change from one release to the next, and you could update the reverse proxy settings with each pull request.

NeoLegends commented 1 year ago

But, every time the url changed, your users would have to re-register as there would be no matching url in the passkeys store in their google/apple/microsoft keychain.

This is already the case with dynamic subdomains per branch, given that due to the separate subdomains and Same-Origin-Rules credentials are not shared between the production and testing/preview domains.

EDIT: This is not entirely true as users just need to re-authenticate, not re-register.

One way to deal with this constraint is to use subdomains for your environments... like: https://localhost:3000/ https://dev.yourdomain.com/ https://test.yourdomain.com/ https://stage.yourdomain.com/ https://www.yourdomain.com/

This pattern is what deploy previews per branch/PR set out to solve. Doing it this classic way is problematic, because it only ever gives you one stage/test/dev environment for /all/ features you're currently working on.

In reality, separate features are developed in isolation from each other in separate branches. Having a separate domain per branch allows separately previewing these features in isolation from each other. In addition to that, this makes the content on one deploy-preview URL pretty stable, so the URL can be passed on to the customer for their own testing. Passing around a single test domain will quickly give you trouble.

I.e. while doing it this way would work well for passkeys, the consequences would be so impractical this not a realistic choice.

and besides this would allow a regex of /.*/ to match any origin

Absolutely, this is the risky part.

Your remedy wouldn't work because the Relying Party ID is a string and is part of the cryptographic signature being verified

I've yet to fully understand the role of the relying party ID. To me it serves the same purpose as the (expected) origin.

Under the assumption users would have to re-register for every domain anyway, would it be viable (though risky) to allow regexes here as well? You can already pass a list of expected RP IDs here.

You can read the assigned deploy-preview url from the host and pass that as the expected origin.

This might work? This would also work for the RP ID.

NeoLegends commented 1 year ago

Using the origin works well for this use case. I think this is a solution I'm willing to work with. Thanks!

intellix commented 2 weeks ago

sorry to revive this old issue but I have some questions regarding this that maybe you know the answer to.

The RPID contains the domain and is already validated within the browser (that it contains the domain), for example with an RPID of yourdomain.com, these are possible:

So what's the purpose of validating the origin within the backend? I assume it's just an additional layer of security (even though it can be spoofed anyway).

I can see that you already came up with the same solution I'm thinking of and that's using a list of strings/regexes to first verify the origin is trusted, and then feed that into this library, so for example:

TRUSTED_ORIGINS = /^((\w+.)*\w+.)?yourdomain.com$/

expectedOrigin: isUriTrusted(trustedHosts, req.headers.origin) ? req.headers.origin : '',

So then you run the actual request.origin through a function to check that you trust it and then if you do, pass it to expectedOrigin