mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
fix(auth): a host may receive a code at its own name, and a refusal is not a redirect
Two changes to who may start a flow here, and where a refusal is delivered. A host reached at its own hostname sits on a different registrable domain from this issuer, deliberately: that is what stops a cookie set there from ever reaching this one. The default rule allows a redirect back to whatever hostname the request arrived on, so it refused exactly the case the separation created. Which is a real problem rather than a theoretical one, because a session cookie without a Domain attribute is host-only, so a browser arriving at one of those hostnames for the first time carries no cookie whether or not it is signed in, and sending it here to sign in again changes nothing. So a client id that is a single hostname under that zone, whose redirect_uri is https and that same hostname at one reserved path, is allowed. Making the client id the hostname is the load-bearing part: a token's audience is its client id, so the session that comes back is bound to the host it will live on and is not a credential anywhere else. Separately, and worth its own paragraph: a refused client's redirect_uri was still used to report the refusal. The check that approves that URI is the one that just failed, so /authorize was an open redirector to anywhere at all -- no sign-in required, on the hostname people are asked to type a password into. It is now a page here. Before: GET /authorize?client_id=web&redirect_uri=https://somewhere.example/callback -> 302 https://somewhere.example/callback?error=unauthorized_client
This commit is contained in:
@@ -2,6 +2,7 @@ import type { Hyperdrive } from '@cloudflare/workers-types';
|
||||
import { issuer } from '@nestri/auth/index';
|
||||
import { CodeProvider } from '@nestri/auth/provider/code';
|
||||
import { CodeUI } from '@nestri/auth/ui/code';
|
||||
import { isDomainMatch } from '@nestri/auth/util';
|
||||
import { Actor } from '@nestri/core/actor';
|
||||
import { PostgresCodeStore } from '@nestri/core/auth/authorization-code';
|
||||
import { PostgresDeviceStore } from '@nestri/core/auth/device-grant';
|
||||
@@ -45,6 +46,72 @@ type Env = {
|
||||
*/
|
||||
const DEVICE_CLIENTS = new Set(['desktop']);
|
||||
|
||||
/**
|
||||
* The zone every user-owned host is reached under, and the one path on it that
|
||||
* may receive an authorization code.
|
||||
*
|
||||
* A host is reached at `<id>.<zone>` through a proxy that authenticates
|
||||
* browsers on its behalf. That proxy cannot be handed a session from here: a
|
||||
* `__Host-` cookie is host-only by definition, so one set on this hostname is
|
||||
* never sent to a different one, and a first request to a host's own name
|
||||
* therefore arrives with no cookie whether or not the person is signed in.
|
||||
*
|
||||
* The proxy closes that by being an ordinary OAuth client — one per hostname —
|
||||
* and exchanging a code for a session it can set on the hostname the browser is
|
||||
* actually standing on. This is the rule that lets it: **the client id must be
|
||||
* the hostname, and the redirect must be that same hostname at the reserved
|
||||
* path below.**
|
||||
*
|
||||
* Making the client id the hostname is not a naming convention. A token is
|
||||
* minted with its audience set to the client id, so it binds the session to the
|
||||
* host it will live on — a cookie lifted off one host is not a credential on
|
||||
* another, and it is not a credential here either. ref(d-0056)
|
||||
*/
|
||||
const HOST_ZONE = 'nestri.link';
|
||||
const HOST_CALLBACK_PATH = '/__nestri/callback';
|
||||
|
||||
/**
|
||||
* Whether `clientID` names a single host under {@link HOST_ZONE} and
|
||||
* `redirectURI` is that same host's reserved callback.
|
||||
*
|
||||
* Every clause is load-bearing, because what is being decided is where this
|
||||
* issuer will send an authorization code:
|
||||
*
|
||||
* - **`https` only.** A code is a one-time credential and belongs on a channel
|
||||
* that cannot be read.
|
||||
* - **The host must equal the client id exactly**, so a client can only ever
|
||||
* receive a code at its own name.
|
||||
* - **One label under the zone.** `a.b.<zone>` is not a host id, and must not
|
||||
* be treated as one because `b.<zone>` might be.
|
||||
* - **The path must be exactly the reserved one**, with no query and no
|
||||
* fragment. A caller-chosen return address on a wildcard of hostnames is an
|
||||
* open redirector on every one of them, and this is the parameter that would
|
||||
* be it.
|
||||
*/
|
||||
function isHostCallback(clientID: string, redirectURI: string): boolean {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(redirectURI);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const label = clientID.toLowerCase().endsWith(`.${HOST_ZONE}`)
|
||||
? clientID.toLowerCase().slice(0, -`.${HOST_ZONE}`.length)
|
||||
: null;
|
||||
if (!label || label.length === 0 || label.includes('.')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
url.protocol === 'https:' &&
|
||||
url.host === clientID.toLowerCase() &&
|
||||
url.pathname === HOST_CALLBACK_PATH &&
|
||||
url.search === '' &&
|
||||
url.hash === ''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enough of an address to be worth trying to deliver to.
|
||||
*
|
||||
@@ -68,6 +135,40 @@ async function firstSteamLink(userID: string): Promise<string> {
|
||||
return link?.id ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Which clients may start a flow here.
|
||||
*
|
||||
* The default rule allows a redirect back to whatever hostname the request
|
||||
* arrived on, which is right for a site served beside this one and refuses the
|
||||
* proxy in front of user-owned hosts — it redirects to a different registrable
|
||||
* domain on purpose, so that no host's cookie can ever reach this one. That
|
||||
* case is named here; everything else keeps the behaviour it had.
|
||||
*
|
||||
* Exported so it can be tested against a real `/authorize` request rather than
|
||||
* by reading it.
|
||||
*/
|
||||
export const allowClient = async (
|
||||
input: { clientID: string; redirectURI: string },
|
||||
req: Request
|
||||
): Promise<boolean> => {
|
||||
if (isHostCallback(input.clientID, input.redirectURI)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let redirect: string;
|
||||
try {
|
||||
redirect = new URL(input.redirectURI).hostname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (redirect === 'localhost' || redirect === '127.0.0.1') {
|
||||
return true;
|
||||
}
|
||||
const forwarded = req.headers.get('x-forwarded-host');
|
||||
const host = forwarded ? new URL(`https://${forwarded}`).hostname : new URL(req.url).hostname;
|
||||
return isDomainMatch(redirect, host);
|
||||
};
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx?: ExecutionContext) {
|
||||
Env.init(env as unknown as Record<string, unknown>);
|
||||
@@ -90,6 +191,15 @@ export default {
|
||||
refreshStore: PostgresRefreshStore(),
|
||||
deviceStore: PostgresDeviceStore(),
|
||||
allowDeviceClient: async (clientID) => DEVICE_CLIENTS.has(clientID),
|
||||
// The default rule allows a redirect back to whatever hostname the
|
||||
// request arrived on, which is right for a site served beside this
|
||||
// one and refuses the proxy in front of user-owned hosts — it
|
||||
// redirects to a different registrable domain on purpose, so that
|
||||
// no host's cookie can ever reach this one.
|
||||
//
|
||||
// So that case is named, and everything else keeps the behaviour it
|
||||
// had.
|
||||
allow: allowClient,
|
||||
// One provider, on purpose.
|
||||
//
|
||||
// Verifying an email address is the only thing that brings an
|
||||
|
||||
Reference in New Issue
Block a user