Skip to content

Commit 8aba52e

Browse files
committed
fix: Harden docs security endpoints
1 parent 709c9d4 commit 8aba52e

8 files changed

Lines changed: 700 additions & 101 deletions

File tree

apps/docs/app/actions/feedback/index.ts

Lines changed: 120 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,38 +3,139 @@
33
import { headers } from "next/headers";
44
import type { Feedback } from "@/components/geistdocs/feedback";
55
import { siteId } from "@/geistdocs";
6+
import { checkRateLimit } from "@/lib/rate-limit";
7+
import { getClientIp } from "@/lib/request-ip";
68
import { emotions } from "./emotions";
79

810
const protocol = process.env.NODE_ENV === "production" ? "https" : "http";
9-
const baseUrl = `${protocol}://${process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL}`;
11+
const MAX_FEEDBACK_MESSAGE_LENGTH = 2000;
12+
const MAX_FEEDBACK_URL_LENGTH = 2048;
13+
const FEEDBACK_RATE_LIMIT = {
14+
limit: 5,
15+
windowSeconds: 60
16+
} as const;
17+
18+
type HeaderList = Awaited<ReturnType<typeof headers>>;
19+
20+
function getBaseUrl(headersList: HeaderList): URL | null {
21+
const productionUrl = process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL;
22+
23+
if (productionUrl) {
24+
try {
25+
return new URL(
26+
productionUrl.startsWith("http")
27+
? productionUrl
28+
: `${protocol}://${productionUrl}`
29+
);
30+
} catch {
31+
return null;
32+
}
33+
}
34+
35+
if (process.env.VERCEL_ENV === "production") {
36+
return null;
37+
}
38+
39+
const host = headersList.get("host");
40+
if (!host) {
41+
return null;
42+
}
43+
44+
const forwardedProto = headersList
45+
.get("x-forwarded-proto")
46+
?.split(",")
47+
.at(0)
48+
?.trim();
49+
const requestProtocol =
50+
forwardedProto === "http" || forwardedProto === "https"
51+
? forwardedProto
52+
: protocol;
53+
54+
return new URL(`${requestProtocol}://${host}`);
55+
}
56+
57+
function getValidatedFeedbackUrl(url: string, baseUrl: URL): string | null {
58+
if (url.length > MAX_FEEDBACK_URL_LENGTH) {
59+
return null;
60+
}
61+
62+
try {
63+
const feedbackUrl = new URL(url, baseUrl);
64+
65+
if (feedbackUrl.origin !== baseUrl.origin) {
66+
return null;
67+
}
68+
69+
return feedbackUrl.toString();
70+
} catch {
71+
return null;
72+
}
73+
}
1074

1175
export const sendFeedback = async (
1276
url: string,
1377
feedback: Feedback
1478
): Promise<{ success: boolean }> => {
15-
const emoji = emotions.find((e) => e.name === feedback.emotion)?.emoji;
16-
const endpoint = new URL("/feedback", "https://geistdocs.com/feedback");
1779
const headersList = await headers();
80+
const baseUrl = getBaseUrl(headersList);
81+
const feedbackUrl =
82+
baseUrl && typeof url === "string"
83+
? getValidatedFeedbackUrl(url, baseUrl)
84+
: null;
85+
const candidateFeedback = feedback as Partial<Feedback> | null | undefined;
1886

19-
const response = await fetch(endpoint, {
20-
method: "POST",
21-
headers: {
22-
"Content-Type": "application/json"
23-
},
24-
body: JSON.stringify({
25-
note: feedback.message,
26-
url: new URL(url, baseUrl).toString(),
27-
emotion: emoji,
28-
ua: headersList.get("user-agent") ?? undefined,
29-
ip: headersList.get("x-real-ip") || headersList.get("x-forwarded-for"),
30-
label: siteId
31-
})
87+
if (
88+
!candidateFeedback ||
89+
typeof candidateFeedback.message !== "string" ||
90+
typeof candidateFeedback.emotion !== "string"
91+
) {
92+
return { success: false };
93+
}
94+
95+
const emoji = emotions.find((e) => e.name === candidateFeedback.emotion)
96+
?.emoji;
97+
const message = candidateFeedback.message.trim();
98+
99+
if (
100+
!feedbackUrl ||
101+
!emoji ||
102+
message.length === 0 ||
103+
message.length > MAX_FEEDBACK_MESSAGE_LENGTH
104+
) {
105+
return { success: false };
106+
}
107+
108+
const rateLimit = await checkRateLimit({
109+
namespace: "feedback",
110+
key: getClientIp(headersList),
111+
...FEEDBACK_RATE_LIMIT
32112
});
33113

34-
if (!response.ok) {
35-
const error = await response.json();
114+
if (!rateLimit.success) {
115+
return { success: false };
116+
}
117+
118+
try {
119+
const response = await fetch("https://geistdocs.com/feedback", {
120+
method: "POST",
121+
headers: {
122+
"Content-Type": "application/json"
123+
},
124+
body: JSON.stringify({
125+
note: message,
126+
url: feedbackUrl,
127+
emotion: emoji,
128+
label: siteId
129+
})
130+
});
131+
132+
if (!response.ok) {
133+
console.error("Feedback request failed:", response.status);
36134

37-
console.error(error);
135+
return { success: false };
136+
}
137+
} catch (error) {
138+
console.error("Feedback request failed:", error);
38139

39140
return { success: false };
40141
}

0 commit comments

Comments
 (0)