stack/apps/backend/src/lib/end-users.tsx
Mantra bb277d33c9
Backend fallback (cloud run) (#1306)
- Added support for `@opentelemetry/sdk-node` in the backend.
- Updated various dependencies including AWS SDK and OpenTelemetry
packages.
- Implemented graceful shutdown handling for non-Vercel runtimes in
`prisma-client.tsx`.
- Enhanced AWS credentials retrieval to support GCP Workload Identity
Federation.
- Introduced a Dockerfile for Cloud Run deployment, optimizing the
backend build process.
- Updated `.gitignore` to include Terraform runtime files and secrets.

This commit improves the backend's observability and deployment
flexibility, particularly for Cloud Run environments.

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* OpenTelemetry observability with dynamic provider selection per
deployment.
  * Cloud Run trusted-proxy support for accurate client IP handling.
  * Graceful shutdown that waits for in-flight background work.
* New background-task handling to improve async webhook/email delivery
reliability.
* AWS credential providers added (Vercel OIDC & GCP Workload Identity
Federation).
  * Dockerized backend image for Cloud Run / self-host deployments.

* **Chores**
  * Updated dependencies for OpenTelemetry and AWS SDK support.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>
2026-04-11 00:57:37 +00:00

319 lines
13 KiB
TypeScript

import { normalizeCountryCode } from "@stackframe/stack-shared/dist/utils/country-codes";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { isIpAddress } from "@stackframe/stack-shared/dist/utils/ips";
import { pick } from "@stackframe/stack-shared/dist/utils/objects";
import { headers } from "next/headers";
// An end user is a person sitting behind a computer screen.
//
// For example, if my-stack-app.com is using Stack Auth, and person A is on my-stack-app.com and sends a server action
// to server B of my-stack-app.com, then the end user is person A, not server B.
//
// An end user is not the same as a ProjectUser. For example, if person A is not logged into
// my-stack-app.com, they are still considered an end user, and will have an associated IP address.
/**
* Returns the end user's IP address from the current request's headers, or `undefined` if it can't be determined.
* Falls back to spoofable headers (x-forwarded-for) if no trusted proxy header (cf-connecting-ip,
* x-vercel-forwarded-for) is available.
*/
export async function getSpoofableEndUserIp(): Promise<string | undefined> {
const endUserInfo = await getEndUserInfo();
return endUserInfo?.maybeSpoofed ? endUserInfo.spoofedInfo.ip : endUserInfo?.exactInfo.ip;
}
/**
* Returns the end user's IP only if it came from a trusted proxy header (cf-connecting-ip or x-vercel-forwarded-for).
* Returns `undefined` if the IP could only be determined from spoofable headers.
*/
export async function getExactEndUserIp(): Promise<string | undefined> {
const endUserInfo = await getEndUserInfo();
return endUserInfo?.maybeSpoofed ? undefined : endUserInfo?.exactInfo.ip;
}
type EndUserLocation = {
countryCode?: string,
regionCode?: string,
cityName?: string,
latitude?: number,
longitude?: number,
tzIdentifier?: string,
};
type TrustedProxy = "" | "vercel" | "cloudflare" | "cloudrun";
export async function getSpoofableEndUserLocation(): Promise<EndUserLocation | null> {
const endUserInfo = await getEndUserInfo();
if (!endUserInfo) {
return null;
}
const locationInfo = getLocationInfo(endUserInfo);
return pick(locationInfo, ["countryCode", "regionCode", "cityName", "latitude", "longitude", "tzIdentifier"]);
}
export type BestEffortEndUserRequestContext = {
ipAddress: string | null,
ipTrusted: boolean | null,
location: EndUserLocation | null,
};
export async function getBestEffortEndUserRequestContext(): Promise<BestEffortEndUserRequestContext> {
const endUserInfo = await getEndUserInfo();
if (!endUserInfo) {
return {
ipAddress: null,
ipTrusted: null,
location: null,
};
}
const locationInfo = getLocationInfo(endUserInfo);
return {
ipAddress: locationInfo.ip,
ipTrusted: !endUserInfo.maybeSpoofed,
location: pick(locationInfo, ["countryCode", "regionCode", "cityName", "latitude", "longitude", "tzIdentifier"]),
};
}
type EndUserInfoInner = EndUserLocation & { ip: string }
function getLocationInfo(endUserInfo: { maybeSpoofed: true, spoofedInfo: EndUserInfoInner } | { maybeSpoofed: false, exactInfo: EndUserInfoInner }) {
return endUserInfo.maybeSpoofed ? endUserInfo.spoofedInfo : endUserInfo.exactInfo;
}
function parseCoordinate(raw: string | null | undefined): number | undefined {
if (!raw) return undefined;
const parsed = parseFloat(raw);
return Number.isFinite(parsed) ? parsed : undefined;
}
function getBrowserEndUserInfo(allHeaders: Headers, trustedProxy: TrustedProxy):
| { maybeSpoofed: true, spoofedInfo: EndUserInfoInner }
| { maybeSpoofed: false, exactInfo: EndUserInfoInner }
| null {
const isVercelTrusted = trustedProxy === "vercel";
const isCloudflareTrusted = trustedProxy === "cloudflare";
const isCloudRunTrusted = trustedProxy === "cloudrun";
// Only read proxy headers as trusted when the corresponding proxy is configured.
// Google Cloud's HTTP(S) LB appends two entries to X-Forwarded-For:
// <client-supplied>, <real-client-ip>, <lb-ip>
// So the real client IP is the second-to-last entry (.at(-2)).
// See: https://cloud.google.com/load-balancing/docs/https#x-forwarded-for_header
const trustedIp = (isVercelTrusted ? allHeaders.get("x-vercel-forwarded-for") : undefined)
?? (isCloudflareTrusted ? allHeaders.get("cf-connecting-ip") : undefined)
?? (isCloudRunTrusted ? allHeaders.get("x-forwarded-for")?.split(",").at(-2)?.trim() : undefined)
?? undefined;
// All other IP headers are always spoofable — including proxy headers when the proxy is not configured as trusted
const spoofableIp = allHeaders.get("x-real-ip")
?? (!isCloudRunTrusted ? allHeaders.get("x-forwarded-for")?.split(",").at(0) : undefined)
?? (!isVercelTrusted ? allHeaders.get("x-vercel-forwarded-for") : undefined)
?? (!isCloudflareTrusted ? allHeaders.get("cf-connecting-ip") : undefined)
?? undefined;
const ip = trustedIp ?? spoofableIp;
if (!ip || !isIpAddress(ip)) {
console.warn("getEndUserIp() found IP address in headers, but is invalid. This is most likely a misconfigured client", { ip, headers: Object.fromEntries(allHeaders) });
return null;
}
// Geo headers are only trustworthy when they come from a verified proxy.
// If a trusted proxy is configured but it did not provide its trusted IP header,
// treat its geo headers as spoofed too.
const rawCountryCode = (isVercelTrusted ? allHeaders.get("x-vercel-ip-country") : undefined)
?? (isCloudflareTrusted ? allHeaders.get("cf-ipcountry") : undefined)
?? undefined;
const geoLocation: EndUserLocation = {
countryCode: rawCountryCode ? normalizeCountryCode(rawCountryCode) : undefined,
regionCode: (isVercelTrusted ? allHeaders.get("x-vercel-ip-country-region") : undefined) || undefined,
cityName: (isVercelTrusted ? allHeaders.get("x-vercel-ip-city") : undefined) || undefined,
latitude: parseCoordinate(isVercelTrusted ? allHeaders.get("x-vercel-ip-latitude") : null),
longitude: parseCoordinate(isVercelTrusted ? allHeaders.get("x-vercel-ip-longitude") : null),
tzIdentifier: (isVercelTrusted ? allHeaders.get("x-vercel-ip-timezone") : undefined) || undefined,
};
// When no proxy is trusted, geo headers are spoofable — still include them but under spoofedInfo
const rawSpoofedCountryCode = trustedProxy === "" ? ((allHeaders.get("x-vercel-ip-country") ?? allHeaders.get("cf-ipcountry")) || undefined) : undefined;
const spoofedGeoLocation: EndUserLocation = trustedProxy === "" ? {
countryCode: rawSpoofedCountryCode ? normalizeCountryCode(rawSpoofedCountryCode) : undefined,
regionCode: allHeaders.get("x-vercel-ip-country-region") || undefined,
cityName: allHeaders.get("x-vercel-ip-city") || undefined,
latitude: parseCoordinate(allHeaders.get("x-vercel-ip-latitude")),
longitude: parseCoordinate(allHeaders.get("x-vercel-ip-longitude")),
tzIdentifier: allHeaders.get("x-vercel-ip-timezone") || undefined,
} : {};
if (trustedIp) {
return { maybeSpoofed: false, exactInfo: { ip, ...geoLocation } };
}
return { maybeSpoofed: true, spoofedInfo: { ip, ...(trustedProxy === "" ? spoofedGeoLocation : {}) } };
}
export async function getEndUserInfo(): Promise<
// discriminated union to make sure the user is really explicit about checking the maybeSpoofed field
| { maybeSpoofed: true, spoofedInfo: EndUserInfoInner }
| { maybeSpoofed: false, exactInfo: EndUserInfoInner }
| null
> {
const allHeaders = await headers();
// note that this is just the requester claiming to be a browser; we can't trust them as they could just fake the
// headers
//
// but in this case, there's no reason why an attacker would want to fake it
//
// this works for all modern browsers because Mozilla is part of the user agent of all of them
// https://stackoverflow.com/a/1114297
const isClaimingToBeBrowser = ["Mozilla", "Chrome", "Safari"].some(header => allHeaders.get("User-Agent")?.includes(header));
if (isClaimingToBeBrowser) {
// Determine which proxy we trust based on deployment configuration.
// These headers can only be trusted when the origin is exclusively reachable through the proxy;
// STACK_TRUSTED_PROXY should be set to "vercel", "cloudflare", "cloudrun", or left empty/unset for no proxy trust.
const trustedProxy = getEnvVariable("STACK_TRUSTED_PROXY", "").toLowerCase().trim();
if (trustedProxy !== "" && trustedProxy !== "vercel" && trustedProxy !== "cloudflare" && trustedProxy !== "cloudrun") {
throw new StackAssertionError(`STACK_TRUSTED_PROXY must be "vercel", "cloudflare", "cloudrun", or empty/unset, but got: "${trustedProxy}"`);
}
return getBrowserEndUserInfo(allHeaders, trustedProxy);
}
/**
* Specifies whether this request is coming from a trusted server (ie. a server with a valid secret server key).
*
* If a trusted server gives us an end user IP, then we always trust them.
*
* TODO we don't currently check if the server is trusted, and always assume false. fix that
*/
const isTrustedServer = false as boolean;
if (isTrustedServer) {
// TODO we currently don't do anything to find the IP address if the request is coming from a trusted server, so
// this is never set to true
// we should fix that, by storing IP information in X-Stack-Requester in the StackApp interface on servers, and then
// reading that information
throw new StackAssertionError("getEndUserIp() is unimplemented for trusted servers");
}
// we don't know anything about this request
// most likely it's a consumer of our REST API that doesn't use our SDKs
return null;
}
import.meta.vitest?.describe("getBrowserEndUserInfo(...)", () => {
const { expect, test } = import.meta.vitest!;
test("does not trust Vercel geo headers when the trusted Vercel IP header is absent", () => {
const result = getBrowserEndUserInfo(new Headers({
"user-agent": "Mozilla/5.0",
"x-forwarded-for": "203.0.113.10",
"x-vercel-ip-country": "DE",
"x-vercel-ip-country-region": "BE",
"x-vercel-ip-city": "Berlin",
"x-vercel-ip-latitude": "52.52",
"x-vercel-ip-longitude": "13.40",
"x-vercel-ip-timezone": "Europe/Berlin",
}), "vercel");
expect(result).toEqual({
maybeSpoofed: true,
spoofedInfo: {
ip: "203.0.113.10",
},
});
});
test("trusts second-to-last x-forwarded-for entry when Cloud Run proxy is configured", () => {
// Google Cloud LB appends: <client-supplied>, <real-client-ip>, <lb-ip>
const result = getBrowserEndUserInfo(new Headers({
"user-agent": "Mozilla/5.0",
"x-forwarded-for": "198.51.100.42, 10.0.0.1",
}), "cloudrun");
expect(result).toEqual({
maybeSpoofed: false,
exactInfo: {
ip: "198.51.100.42",
},
});
});
test("ignores client-spoofed x-forwarded-for entries for Cloud Run proxy", () => {
// Client sends "1.1.1.1", LB appends real client IP and its own IP
const result = getBrowserEndUserInfo(new Headers({
"user-agent": "Mozilla/5.0",
"x-forwarded-for": "1.1.1.1, 198.51.100.42, 10.0.0.1",
}), "cloudrun");
expect(result).toEqual({
maybeSpoofed: false,
exactInfo: {
ip: "198.51.100.42",
},
});
});
test("does not expose x-forwarded-for as spoofable when Cloud Run proxy is configured", () => {
const result = getBrowserEndUserInfo(new Headers({
"user-agent": "Mozilla/5.0",
"x-forwarded-for": "198.51.100.42, 10.0.0.1",
"x-real-ip": "10.0.0.1",
}), "cloudrun");
expect(result).toEqual({
maybeSpoofed: false,
exactInfo: {
ip: "198.51.100.42",
},
});
});
test("does not trust geo headers for Cloud Run proxy", () => {
const result = getBrowserEndUserInfo(new Headers({
"user-agent": "Mozilla/5.0",
"x-forwarded-for": "198.51.100.42, 10.0.0.1",
"x-vercel-ip-country": "US",
"cf-ipcountry": "DE",
}), "cloudrun");
expect(result).toEqual({
maybeSpoofed: false,
exactInfo: {
ip: "198.51.100.42",
},
});
});
test("keeps trusted proxy geo headers when the trusted IP header is present", () => {
const result = getBrowserEndUserInfo(new Headers({
"user-agent": "Mozilla/5.0",
"x-vercel-forwarded-for": "203.0.113.10",
"x-vercel-ip-country": "DE",
"x-vercel-ip-country-region": "BE",
"x-vercel-ip-city": "Berlin",
"x-vercel-ip-latitude": "52.52",
"x-vercel-ip-longitude": "13.40",
"x-vercel-ip-timezone": "Europe/Berlin",
}), "vercel");
expect(result).toEqual({
maybeSpoofed: false,
exactInfo: {
ip: "203.0.113.10",
countryCode: "DE",
regionCode: "BE",
cityName: "Berlin",
latitude: 52.52,
longitude: 13.4,
tzIdentifier: "Europe/Berlin",
},
});
});
});