CVE-2024-21583 (TypeScript, published 2024-07-19T05:15Z)
Description: value: Versions of the package github.com/gitpod-io/gitpod/components/server/go/pkg/lib before main-gha.27122; versions of the package github.com/gitpod-io/gitpod/components/ws-proxy/pkg/proxy before main-gha.27122; versions of the package github.com/gitpod-io/gitpod/install/installer/pkg/components/auth before main-gha.27122; versions of the package github.com/gitpod-io/gitpod/install/installer/pkg/components/public-api-server before main-gha.27122; versions of the package github.com/gitpod-io/gitpod/install/installer/pkg/components/server before main-gha.27122; versions of the package @gitpod/gitpod-protocol before 0.1.5-main-gha.27122 are vulnerable to Cookie Tossing due to a missing __Host- prefix on the _gitpod_io_jwt2_ session cookie. This allows an adversary who controls a subdomain to set the value of the cookie on the Gitpod control plane, which can be assigned to an attacker’s own JWT so that specific actions taken by the victim (such as connecting a new Github organization) are actioned by the attackers session.
Severity: nan (CVSS v3 base score: )
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/gitpod-io/gitpod
Files changed:
components/gitpod-protocol/src/util/gitpod-host-url.ts
components/server/go/pkg/lib/cookie.go
components/server/src/auth/generic-auth-provider.ts
components/server/src/auth/login-completion-handler.ts
components/server/src/prometheus-metrics.ts
components/server/src/session-handler.spec.db.ts
components/server/src/session-handler.ts
components/server/src/test/service-testing-container-module.ts
components/server/src/user/user-controller.ts
components/ws-proxy/pkg/proxy/routes.go
components/ws-proxy/pkg/proxy/routes_test.go
install/installer/pkg/components/auth/config_test.go
install/installer/pkg/components/public-api-server/configmap_test.go
install/installer/pkg/components/server/configmap_test.go
Commit message: Make auth cookie stricter (#19973)

* Use `__Host-` prefix for cookie

* Fix tests

* Remove domain from cookie

* Fix logout

* remove unused fn

* fix user logout properly

* [server] Make domain-only cookie work for GitHub oauth login

... by adding additional step so we can set the cookie for the base domain only

* test: fix by redirecting before callbacl/authorize

* [server] SessionHandler: Allow to login with both primary and secondary cookies

* [server] Clear 2ndary cookie on logout

* Fix filtering cookie values when primary cookie is empty

* Fix logouts

* Fix tests

---------

Co-authored-by: Gero Posmyk-Leinemann <gero@gitpod.io>

Vulnerable code:
	return "_" + derived + "_jwt2_"
            expect(opts.sameSite).to.equal("strict");
            expect(name, "Check cookie name").to.equal("_gitpod_dev_jwt_");
            const user = req.user;
            const jwtTokens = cookies[getJWTCookieName(this.config)];
        const cookieValues = cookies[getJWTCookieName(this.config)];
            name: getJWTCookieName(this.config),
                domain: getJWTCookieDomain(this.config),
    public clearSessionCookie(res: express.Response, config: Config): void {
        res.clearCookie(getJWTCookieName(this.config), {
            domain: getJWTCookieDomain(config),
function getJWTCookieName(config: Config) {
function getJWTCookieDomain(config: Config): string {
    return config.hostUrl.url.hostname;
            name: "_gitpod_dev_jwt_",
            sameSite: "strict",
            this.sessionHandler.clearSessionCookie(res, this.config);
        log.debug("The redirect URL does not match", { query: req.query });
		if strings.HasPrefix(c.Name, hostnamePrefix) {
			// skip session cookie
		sessionCookieJwt2       = &http.Cookie{Domain: domain, Name: "_test_domain_com_jwt2_", Value: "fobar"}
			expectedOutcome: "_example_com_jwt2_",
			expectedOutcome: "_example_test_com_jwt2_",
			expectedOutcome: "_example_test_com_jwt2_",
			expectedOutcome: "_example_test_com_jwt2_",
			expectedOutcome: "_subdomain_example_com_jwt2_",
			expectedOutcome: "_sub_domain_example_com_jwt2_",
			expectedOutcome: "_sub_domain_example_com_jwt2_",
			expectedOutcome: "_sub_domain_example_com_jwt2_",
					Name:     "_test_domain_everything_awesome_is_jwt2_",
					Name:     "_awesome_domain_jwt2_",

Fixed code:

    asLoginWithOTS(userId: string, key: string, returnToUrl?: string) {
        const result = this.withApi({ pathname: `/login/ots/${userId}/${key}` });
        if (returnToUrl) {
            return result.with({ search: `returnTo=${encodeURIComponent(returnToUrl)}` });
        }
        return result;
    }
	return "__Host-_" + derived + "_jwt2_"
        if (!this.loginCompletionHandler.isBaseDomain(request)) {
            // For auth requests that are not targetting the base domain, we redirect to the base domain, so they come with our cookie.
            log.info(`(${strategyName}) Auth request on subdomain, redirecting to base domain`, { clientInfo });
            const target = new URL(request.url, this.config.hostUrl.url.toString()).toString();
            response.redirect(target);
            return;
        }

import * as crypto from "crypto";
import { OneTimeSecretServer } from "../one-time-secret-server";
    @inject(OneTimeSecretServer) private readonly otsServer: OneTimeSecretServer;
        if (!this.isBaseDomain(request)) {
            // (GitHub edge case) If we got redirected here onto a sub-domain (e.g. api.gitpod.io), we need to redirect to the base domain in order to Set-Cookie properly.
            const secret = crypto
                .createHash("sha256")
                .update(user.id + this.config.session.secret)
                .digest("hex");
            const expirationDate = new Date(Date.now() + 1000 * 60); // 1 minutes
            const token = await this.otsServer.serveToken({}, secret, expirationDate);

            reportLoginCompleted("succeeded_via_ots", "git");
            log.info(
                logContext,
                `User will be logged in via OTS on the base domain. (Indirect) redirect to: ${returnTo}`,
            );
            const baseDomainRedirect = this.config.hostUrl.asLoginWithOTS(user.id, token.token, returnTo).toString();
            response.redirect(baseDomainRedirect);
            return;
        }

        // (default case) If we got redirected here onto the base domain of the Gitpod installation, we can just issue the cookie right away.
    public isBaseDomain(req: express.Request): boolean {
        return req.hostname === this.config.hostUrl.url.hostname;
    }

    // The login was successful, but we need to defer cookie creation via an OTS
    | "succeeded_via_ots"
            expect(opts.sameSite).to.equal("lax");
            expect(name, "Check cookie name").to.equal("__Host-_gitpod_dev_jwt_");

        it("old JWT cookie is present, is accepted (!), and we get a new one", async () => {
            const oldExpiredCookie = await sessionHandler.createJWTSessionCookie(existingUser.id, {
                issuedAtMs: Date.now() - SessionHandler.JWT_REFRESH_THRESHOLD - 1,
            });
            oldExpiredCookie.name = "_gitpod_dev_jwt_";
            const newCookie = await sessionHandler.createJWTSessionCookie(existingUser.id);

            const res = await handle(existingUser, `${oldExpiredCookie.name}=${oldExpiredCookie.value}`);
            expect(res.status).to.equal(200);
            expect(res.value).to.equal("Refreshed JWT cookie issued.");
            expect(res.cookie).to.not.be.undefined;
            expect(res.cookie?.split("=")[0]).to.equal(newCookie.name);
        });

        it("old expired AND new one JWT cookies are present, new one is accepted", async () => {
            const oldExpiredCookie = await sessionHandler.createJWTSessionCookie(existingUser.id, {
                issuedAtMs: Date.now() - SessionHandler.JWT_REFRESH_THRESHOLD - 1,
            });
            oldExpiredCookie.name = "_gitpod_dev_jwt_";
            const newCookie = await sessionHandler.createJWTSessionCookie(existingUser.id);

            const res = await handle(
                existingUser,
                `${oldExpiredCookie.name}=${oldExpiredCookie.value}; ${newCookie.name}=${newCookie.value}`,
            );
            expect(res.status).to.equal(200);
            expect(res.value).to.equal("User session already has a valid JWT session.");
            expect(res.cookie).to.be.undefined;
        });
            const { user } = req;
            const jwtTokens = this.filterCookieValues(cookies);
        const cookieValues = this.filterCookieValues(cookies);
    /**
     * @param cookies
     * @returns Primary (the cookie name we set) AND secondary cookie (old accepted cookie name) values (in that order).
     */
    private filterCookieValues(cookies: { [key: string]: string[] }): string[] {
        const cookieValues = cookies[getPrimaryJWTCookieName(this.config)] ?? [];

        const secondaryCookieName = getSecondaryJWTCookieName(this.config);
        if (secondaryCookieName) {
            const secondaryCookieValues = cookies[secondaryCookieName];
            if (secondaryCookieValues) {
                cookieValues.push(...secondaryCookieValues);
            }
        }
        return cookieValues;
    }

            name: getPrimaryJWTCookieName(this.config),
    public clearSessionCookie(res: express.Response): void {
        const { secure, sameSite, httpOnly } = this.config.auth.session.cookie;
        res.clearCookie(getPrimaryJWTCookieName(this.config), {
            httpOnly,
            sameSite,
            secure,

        const secondaryCookieName = getSecondaryJWTCookieName(this.config);
        if (secondaryCookieName) {
            res.clearCookie(secondaryCookieName, {
                domain: this.config.hostUrl.url.hostname,
            });
        }
function getPrimaryJWTCookieName(config: Config) {
function getSecondaryJWTCookieName(config: Config) {
    const PREFIX = "__Host-";
    if (!config.auth.session.cookie.name.startsWith(PREFIX)) {
        return undefined;
    }
    return config.auth.session.cookie.name.slice(PREFIX.length);
            name: "__Host-_gitpod_dev_jwt_",
            sameSite: "lax",
import { TrustedValue } from "@gitpod/gitpod-protocol/lib/util/scrubbing";
                // If returnTo was passed and it's safe, redirect to it
                const returnTo = this.getSafeReturnToParam(req);
                if (returnTo) {
                    log.info(`Redirecting after OTS login ${returnTo}`);
                    res.redirect(returnTo);
                    return;
                }

            this.sessionHandler.clearSessionCookie(res);
        log.debug("The redirect URL does not match", { query: new TrustedValue(req.query).value });
		if strings.HasPrefix(c.Name, hostnamePrefix) || strings.HasPrefix(c.Name, "__Host-"+hostnamePrefix) {
			// skip session cookies
		sessionCookieJwt2       = &http.Cookie{Domain: domain, Name: "__Host-_test_domain_com_jwt2_", Value: "fobar"}
			expectedOutcome: "__Host-_example_com_jwt2_",
			expectedOutcome: "__Host-_example_test_com_jwt2_",
			expectedOutcome: "__Host-_example_test_com_jwt2_",
			expectedOutcome: "__Host-_example_test_com_jwt2_",
			expectedOutcome: "__Host-_subdomain_example_com_jwt2_",
			expectedOutcome: "__Host-_sub_domain_example_com_jwt2_",
			expectedOutcome: "__Host-_sub_domain_example_com_jwt2_",
			expectedOutcome: "__Host-_sub_domain_example_com_jwt2_",
					Name:     "__Host-_test_domain_everything_awesome_is_jwt2_",
					Name:     "__Host-_awesome_domain_jwt2_",