Skip to content

Commit 6d49133

Browse files
author
Rajat
committed
Deployment fixes
1 parent 10056b4 commit 6d49133

6 files changed

Lines changed: 133 additions & 10 deletions

File tree

apps/api/Dockerfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ COPY pnpm-workspace.yaml ./
1111
COPY apps/api ./apps/api
1212
COPY packages/api-contract ./packages/api-contract
1313
COPY packages/email-editor ./packages/email-editor
14+
COPY packages/email-blocks ./packages/email-blocks
1415

1516
RUN pnpm install --frozen-lockfile
1617

@@ -20,6 +21,7 @@ COPY --from=deps /app/ ./
2021

2122
RUN pnpm --filter=@sendlit/email-editor build
2223
RUN pnpm --filter=@sendlit/api-contract build
24+
RUN pnpm --filter=@sendlit/email-blocks build
2325
RUN pnpm --filter=@sendlit/api build
2426

2527
FROM base AS runner
@@ -37,6 +39,9 @@ COPY --chown=nodeuser:nodejs --from=builder /app/pnpm-workspace.yaml ./
3739
COPY --chown=nodeuser:nodejs --from=builder /app/packages/email-editor/package.json ./packages/email-editor/package.json
3840
COPY --chown=nodeuser:nodejs --from=builder /app/packages/email-editor/dist ./packages/email-editor/dist
3941

42+
COPY --chown=nodeuser:nodejs --from=builder /app/packages/email-blocks/package.json ./packages/email-blocks/package.json
43+
COPY --chown=nodeuser:nodejs --from=builder /app/packages/email-blocks/dist ./packages/email-blocks/dist
44+
4045
COPY --chown=nodeuser:nodejs --from=builder /app/packages/api-contract/package.json ./packages/api-contract/package.json
4146
COPY --chown=nodeuser:nodejs --from=builder /app/packages/api-contract/dist ./packages/api-contract/dist
4247

apps/web/app/api/auth/[...path]/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import { API_URL } from "@/lib/config";
3+
import { safeAppRedirect } from "@/lib/safe-app-redirect";
34

45
async function proxyAuth(req: NextRequest, path: string[]) {
56
const upstreamUrl = new URL(
@@ -42,7 +43,9 @@ async function proxyAuth(req: NextRequest, path: string[]) {
4243
path[0] === "sign-out" &&
4344
upstream.ok
4445
) {
45-
const response = NextResponse.redirect(new URL("/login", req.url), {
46+
// Use WEB_CLIENT, not req.url — behind a reverse proxy req.url can be
47+
// the container bind host (0.0.0.0:3000).
48+
const response = NextResponse.redirect(safeAppRedirect("/login"), {
4649
status: 303,
4750
});
4851
for (const cookie of upstream.headers.getSetCookie()) {

apps/web/app/api/team/switch/route.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import { NextRequest } from "next/server";
33

4-
function request(fields: Record<string, string>) {
4+
function request(fields: Record<string, string>, url?: string) {
55
const body = new URLSearchParams(fields);
6-
return new NextRequest("http://localhost:3000/api/team/switch", {
6+
return new NextRequest(url ?? "http://localhost:3000/api/team/switch", {
77
method: "POST",
88
headers: { "content-type": "application/x-www-form-urlencoded" },
99
body,
@@ -13,6 +13,7 @@ function request(fields: Record<string, string>) {
1313
describe("team switch", () => {
1414
beforeEach(() => {
1515
vi.unstubAllEnvs();
16+
vi.resetModules();
1617
});
1718

1819
it("sets the selected team and redirects within the application", async () => {
@@ -21,6 +22,7 @@ describe("team switch", () => {
2122
request({ teamId: "team_123", redirectTo: "/contacts?from=team" }),
2223
);
2324

25+
expect(response.status).toBe(303);
2426
expect(response.headers.get("location")).toBe(
2527
"http://localhost:3000/contacts?from=team",
2628
);
@@ -31,6 +33,22 @@ describe("team switch", () => {
3133
expect(response.headers.get("set-cookie")).not.toContain("HttpOnly");
3234
});
3335

36+
it("redirects using WEB_CLIENT even when req.url is the container bind address", async () => {
37+
vi.stubEnv("WEB_CLIENT", "https://app.sendlit.clqa.site");
38+
vi.resetModules();
39+
const { POST } = await import("./route");
40+
const response = await POST(
41+
request(
42+
{ teamId: "team_123", redirectTo: "/teams" },
43+
"http://0.0.0.0:3000/api/team/switch",
44+
),
45+
);
46+
47+
expect(response.headers.get("location")).toBe(
48+
"https://app.sendlit.clqa.site/teams",
49+
);
50+
});
51+
3452
it.each(["https://attacker.example/steal", "//attacker.example/steal"])(
3553
"rejects an external redirect target: %s",
3654
async (redirectTo) => {

apps/web/app/api/team/switch/route.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NextRequest, NextResponse } from "next/server";
2+
import { safeAppRedirect } from "@/lib/safe-app-redirect";
23
import { TEAM_ID_COOKIE } from "@/lib/tokens";
34

45
/**
@@ -7,19 +8,18 @@ import { TEAM_ID_COOKIE } from "@/lib/tokens";
78
* `apps/api/src/auth/require-team.ts`). Submitted as a regular form POST
89
* (same pattern as `/api/auth/logout`) so switching works with a plain
910
* `<form>`, no client JS required.
11+
*
12+
* Redirects use `WEB_CLIENT` (via `safeAppRedirect`) rather than `req.url`,
13+
* so reverse-proxied deploys don't send the browser to the container bind
14+
* address (`0.0.0.0:3000`).
1015
*/
1116
export async function POST(req: NextRequest) {
1217
const form = await req.formData();
1318
const teamId = String(form.get("teamId") || "");
1419
const redirectTo = String(form.get("redirectTo") || "/");
1520

16-
const requestUrl = new URL(req.url);
17-
const requestedRedirect = new URL(redirectTo, requestUrl);
18-
const redirectUrl =
19-
requestedRedirect.origin === requestUrl.origin
20-
? requestedRedirect
21-
: new URL("/", requestUrl);
22-
const res = NextResponse.redirect(redirectUrl);
21+
// 303: after a form POST, follow the redirect with GET (not re-POST).
22+
const res = NextResponse.redirect(safeAppRedirect(redirectTo), 303);
2323
if (teamId) {
2424
const isProd = process.env.NODE_ENV === "production";
2525
// Not httpOnly, deliberately: this is just a "which team am I looking
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
3+
describe("safeAppRedirect", () => {
4+
afterEach(() => {
5+
vi.unstubAllEnvs();
6+
vi.resetModules();
7+
});
8+
9+
async function loadWithWebClient(webClient: string) {
10+
vi.stubEnv("WEB_CLIENT", webClient);
11+
vi.resetModules();
12+
return import("./safe-app-redirect");
13+
}
14+
15+
it("resolves relative paths against WEB_CLIENT", async () => {
16+
const { safeAppRedirect } = await loadWithWebClient(
17+
"https://app.sendlit.clqa.site",
18+
);
19+
20+
expect(safeAppRedirect("/teams").toString()).toBe(
21+
"https://app.sendlit.clqa.site/teams",
22+
);
23+
expect(safeAppRedirect("/contacts?from=team").toString()).toBe(
24+
"https://app.sendlit.clqa.site/contacts?from=team",
25+
);
26+
});
27+
28+
it("allows absolute URLs on the same public origin", async () => {
29+
const { safeAppRedirect } = await loadWithWebClient(
30+
"https://app.sendlit.example",
31+
);
32+
33+
expect(
34+
safeAppRedirect("https://app.sendlit.example/settings").toString(),
35+
).toBe("https://app.sendlit.example/settings");
36+
});
37+
38+
it.each([
39+
"https://attacker.example/steal",
40+
"//attacker.example/steal",
41+
"https://app.sendlit.evil/teams",
42+
])("rejects external redirect target %s", async (redirectTo) => {
43+
const { safeAppRedirect } = await loadWithWebClient(
44+
"https://app.sendlit.example",
45+
);
46+
47+
expect(safeAppRedirect(redirectTo).toString()).toBe(
48+
"https://app.sendlit.example/",
49+
);
50+
});
51+
52+
it("falls back to home for empty or invalid targets", async () => {
53+
const { safeAppRedirect } = await loadWithWebClient(
54+
"http://localhost:3000",
55+
);
56+
57+
expect(safeAppRedirect("").toString()).toBe("http://localhost:3000/");
58+
expect(safeAppRedirect("not a url").toString()).toBe(
59+
"http://localhost:3000/",
60+
);
61+
});
62+
});

apps/web/lib/safe-app-redirect.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { WEB_CLIENT } from "@/lib/config";
2+
3+
/**
4+
* Resolve a post-action redirect against this app's public origin
5+
* (`WEB_CLIENT`), not `req.url`.
6+
*
7+
* Behind a reverse proxy the Next.js standalone server binds to
8+
* `HOSTNAME=0.0.0.0`, so `req.url` / `req.nextUrl.origin` become
9+
* `http://0.0.0.0:3000` (or similar). Building `Location` from that sends
10+
* the browser to an unreachable address. `WEB_CLIENT` is already the
11+
* canonical public origin used for auth return URLs.
12+
*
13+
* Only same-origin targets are allowed (relative paths, or absolute URLs
14+
* whose origin matches `WEB_CLIENT`). Everything else falls back to `/`.
15+
*/
16+
export function safeAppRedirect(redirectTo: string): URL {
17+
const appOrigin = new URL(WEB_CLIENT);
18+
19+
try {
20+
// Protocol-relative URLs (`//evil.example/...`) must not be treated
21+
// as relative paths — `new URL("//…", origin)` would rewrite the host.
22+
if (redirectTo.startsWith("/") && !redirectTo.startsWith("//")) {
23+
return new URL(redirectTo, appOrigin);
24+
}
25+
26+
const absolute = new URL(redirectTo);
27+
if (absolute.origin === appOrigin.origin) {
28+
return absolute;
29+
}
30+
} catch {
31+
// Invalid URL — fall through to the home path.
32+
}
33+
34+
return new URL("/", appOrigin);
35+
}

0 commit comments

Comments
 (0)