Conversation
|
Non-exhaustive to-do list:
|
| const response = await fetch(targetUrl, { | ||
| method, | ||
| headers: getProxyHeaders(req), | ||
| body, | ||
| redirect: "manual", | ||
| }); |
Check failure
Code scanning / CodeQL
Server-side request forgery Critical
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 2 months ago
In general, to fix this kind of SSRF issue you must constrain how user input can influence the outgoing request URL. That typically means (1) never letting user input control the hostname; (2) validating and normalizing any user-controlled path; and (3) enforcing that the final resolved URL stays within an expected prefix (for example, https://api.example.com/api/), rejecting requests that escape that prefix.
For this specific code, the best fix without changing the intended functionality is to validate the computed targetUrl after constructing it, and to reject any request whose path escapes the proxy’s API prefix. We can do this by computing the resolved targetUrl, then checking that its origin (protocol + host) matches the configured proxyBaseUrl origin, and that its pathname begins with the expected /api/ prefix (matching what getProxyBaseUrl() constructs). If either of these checks fails, we return a 400/403 error instead of forwarding the request. This preserves current behavior for normal, in-scope endpoints while preventing user-supplied endpoint values from redirecting the proxy to an unintended host or path outside the allowed API namespace.
Concretely:
- Right after
const targetUrl = new URL(endpoint, proxyBaseUrl);(lines 117–118), add:- A
URLinstance forproxyBaseUrl(e.g.,const allowedBase = new URL(proxyBaseUrl);). - A comparison that
targetUrl.origin === allowedBase.origin. - A comparison that
targetUrl.pathname.startsWith(allowedBase.pathname)(and ensureallowedBase.pathnameends with/and matches whatgetProxyBaseUrl()returns).
- A
- If either check fails, respond with
res.status(400).send("Invalid legacy API endpoint.");and return. - This change is localized to
apps/client/src/pages/api/rest/[...endpoint].ts, requires no new imports, and keeps existing logic intact for valid endpoints.
| @@ -117,6 +117,22 @@ | ||
| const targetUrl = new URL(endpoint, proxyBaseUrl); | ||
| appendQueryParams(targetUrl, req.query); | ||
|
|
||
| // Ensure the resolved target URL stays within the configured API base to avoid SSRF. | ||
| const allowedBase = new URL(proxyBaseUrl); | ||
| // Require same origin (protocol + host + port). | ||
| if (targetUrl.origin !== allowedBase.origin) { | ||
| res.status(400).send("Invalid legacy API endpoint."); | ||
| return; | ||
| } | ||
| // Require the path to stay under the configured API prefix. | ||
| const allowedPathPrefix = allowedBase.pathname.endsWith("/") | ||
| ? allowedBase.pathname | ||
| : allowedBase.pathname + "/"; | ||
| if (!targetUrl.pathname.startsWith(allowedPathPrefix)) { | ||
| res.status(400).send("Invalid legacy API endpoint."); | ||
| return; | ||
| } | ||
|
|
||
| const method = req.method ?? "GET"; | ||
| const body = method === "GET" || method === "HEAD" ? undefined : await readRequestBody(req); | ||
|
|
|
|
||
| const redirect = sessionStorage.getItem("lapse:oauth_redirect"); | ||
| sessionStorage.removeItem("lapse:oauth_redirect"); | ||
| router.push(redirect ?? "/"); |
Check failure
Code scanning / CodeQL
Client-side cross-site scripting High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 2 months ago
In general, to fix this type of issue you should never use user-provided URLs or paths directly for navigation or DOM insertion. Instead, validate and normalize the value, and either restrict it to a safe whitelist (e.g., specific paths or patterns) or fall back to a safe default when it doesn’t meet your criteria. For redirect parameters, it is common to only allow same-origin, internal paths (like /dashboard), disallow URL schemes (http:, https:, javascript:, etc.), and reject absolute URLs or paths containing suspicious sequences.
For this specific code, the best minimal fix is to sanitize the redirect parameter when reading it from sessionStorage in exchangeToken. We can introduce a small helper function, e.g. sanitizeRedirectPath, that takes a string from storage and returns either a cleaned internal path or null if invalid. Criteria appropriate here to preserve intended functionality while removing the vulnerability:
- Only allow redirects that:
- Start with
/(so they are relative to the app root). - Do not start with
//(which could indicate a protocol-relative external URL). - Do not contain dangerous substrings such as
javascript:(case-insensitive).
- Start with
- Optionally, strip leading/trailing whitespace.
- If the value fails these checks or is absent, return
nullso the code falls back to"/"as before.
We implement this helper near the other local functions inside the same file (e.g., after generateRandomString or near initOAuth), and then change the logic in exchangeToken so that, instead of directly using sessionStorage.getItem("lapse:oauth_redirect"), we call sanitizeRedirectPath and use its result. Concretely, we replace:
const redirect = sessionStorage.getItem("lapse:oauth_redirect");
sessionStorage.removeItem("lapse:oauth_redirect");
router.push(redirect ?? "/");with:
const storedRedirect = sessionStorage.getItem("lapse:oauth_redirect");
sessionStorage.removeItem("lapse:oauth_redirect");
const redirect = sanitizeRedirectPath(storedRedirect);
router.push(redirect ?? "/");and add a sanitizeRedirectPath function in the same file. This preserves the original behavior for simple internal paths like / or /some/page, but prevents using arbitrary user-controlled URLs or scripts as redirect targets. No new imports are needed, and the change is fully contained within apps/client/src/pages/auth.tsx.
| @@ -18,6 +18,22 @@ | ||
| return Array.from(array, b => b.toString(16).padStart(2, "0")).join(""); | ||
| } | ||
|
|
||
| function sanitizeRedirectPath(rawRedirect: string | null): string | null { | ||
| if (!rawRedirect) return null; | ||
|
|
||
| const redirect = rawRedirect.trim(); | ||
|
|
||
| // Only allow same-origin relative paths that start with a single "/" | ||
| if (!redirect.startsWith("/")) return null; | ||
| if (redirect.startsWith("//")) return null; | ||
|
|
||
| // Disallow obvious JavaScript URLs | ||
| const lower = redirect.toLowerCase(); | ||
| if (lower.includes("javascript:")) return null; | ||
|
|
||
| return redirect; | ||
| } | ||
|
|
||
| async function generateCodeChallenge(verifier: string): Promise<string> { | ||
| const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); | ||
| return btoa(String.fromCharCode(...new Uint8Array(hash))) | ||
| @@ -127,8 +143,9 @@ | ||
|
|
||
| posthog.capture("user_signed_in"); | ||
|
|
||
| const redirect = sessionStorage.getItem("lapse:oauth_redirect"); | ||
| const storedRedirect = sessionStorage.getItem("lapse:oauth_redirect"); | ||
| sessionStorage.removeItem("lapse:oauth_redirect"); | ||
| const redirect = sanitizeRedirectPath(storedRedirect); | ||
| router.push(redirect ?? "/"); | ||
| } | ||
| catch (err) { |
|
|
||
| const redirect = sessionStorage.getItem("lapse:oauth_redirect"); | ||
| sessionStorage.removeItem("lapse:oauth_redirect"); | ||
| router.push(redirect ?? "/"); |
Check warning
Code scanning / CodeQL
Client-side URL redirect Medium
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 2 months ago
In general, the fix is to ensure that user-controlled redirect targets are validated and constrained before being used for navigation. Common strategies are: (1) only allow relative paths within your own app (and reject or normalize anything that looks like an absolute URL), and/or (2) maintain a small allowlist of permitted redirect paths or prefixes.
For this snippet, the minimal change that preserves functionality is to sanitize router.query.redirect before saving it, and to only ever navigate to a safe, normalized path. A good approach: when we first read router.query.redirect in initOAuth, validate it. Accept only same-origin, app-internal paths starting with / (and not protocol-relative //), and reject anything that looks like a full URL (http:, https:, etc.). Store only the sanitized path in sessionStorage. Later, when reading from sessionStorage in exchangeToken, pick / as a fallback and push the sanitized path.
Concretely in apps/client/src/pages/auth.tsx:
- Add a small helper function, e.g.
sanitizeRedirect, near the top that:- Returns
/if the input is falsy. - Trims whitespace.
- Rejects if it starts with
http://,https://,//, or contains://. - Ensures it starts with
/; if it doesn’t, prefix/.
- Returns
- In
initOAuth, instead of storingredirectParamdirectly, callsanitizeRedirect(redirectParam)and store that value. - In
exchangeToken, after reading fromsessionStorage, pass it throughsanitizeRedirectbefore callingrouter.push.
This keeps the existing behavior for normal internal redirects (e.g. ?redirect=/dashboard) but prevents redirects to attacker-controlled external sites, addressing the CodeQL warning without changing the external API or the overall OAuth flow.
| @@ -18,6 +18,29 @@ | ||
| return Array.from(array, b => b.toString(16).padStart(2, "0")).join(""); | ||
| } | ||
|
|
||
| function sanitizeRedirect(rawRedirect: string | null): string { | ||
| if (!rawRedirect) return "/"; | ||
|
|
||
| const redirect = rawRedirect.trim(); | ||
| if (!redirect) return "/"; | ||
|
|
||
| const lower = redirect.toLowerCase(); | ||
| if ( | ||
| lower.startsWith("http://") || | ||
| lower.startsWith("https://") || | ||
| lower.startsWith("//") || | ||
| lower.includes("://") | ||
| ) { | ||
| return "/"; | ||
| } | ||
|
|
||
| if (!redirect.startsWith("/")) { | ||
| return "/" + redirect; | ||
| } | ||
|
|
||
| return redirect; | ||
| } | ||
|
|
||
| async function generateCodeChallenge(verifier: string): Promise<string> { | ||
| const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); | ||
| return btoa(String.fromCharCode(...new Uint8Array(hash))) | ||
| @@ -68,8 +91,10 @@ | ||
| sessionStorage.setItem("lapse:oauth_code_verifier", codeVerifier); | ||
|
|
||
| const redirectParam = typeof router.query.redirect === "string" ? router.query.redirect : null; | ||
| if (redirectParam) | ||
| sessionStorage.setItem("lapse:oauth_redirect", redirectParam); | ||
| if (redirectParam) { | ||
| const safeRedirect = sanitizeRedirect(redirectParam); | ||
| sessionStorage.setItem("lapse:oauth_redirect", safeRedirect); | ||
| } | ||
|
|
||
| const redirectUri = `${window.location.origin}/auth`; | ||
|
|
||
| @@ -127,9 +152,10 @@ | ||
|
|
||
| posthog.capture("user_signed_in"); | ||
|
|
||
| const redirect = sessionStorage.getItem("lapse:oauth_redirect"); | ||
| const storedRedirect = sessionStorage.getItem("lapse:oauth_redirect"); | ||
| sessionStorage.removeItem("lapse:oauth_redirect"); | ||
| router.push(redirect ?? "/"); | ||
| const redirect = sanitizeRedirect(storedRedirect); | ||
| router.push(redirect); | ||
| } | ||
| catch (err) { | ||
| posthog.capture("auth_token_exchange_error", { err, query: location.search }); |
Warning
This PR is a breaking change. It changes the way Lapse is deployed and its public and internal API.
This PR implements a microservice structure:
server: handles backend tasksclient: interfaces between the server and the browserworker: encodes timelapses and applies edit listsA non-exhaustive list of features this pull request will implement is as follows:
ffmpeg-powered encoding@hackclub/lapse-apipackageserverandworkerDo note that this PR forces the API to move URLs from
https://lapse.hackclub.comtohttps://api.lapse.hackclub.com.