Skip to content

feat!: separate into microservices and encode on the server (v2.0.0)#146

Merged
ascpixi merged 131 commits intomainfrom
feat/v2
Mar 10, 2026
Merged

feat!: separate into microservices and encode on the server (v2.0.0)#146
ascpixi merged 131 commits intomainfrom
feat/v2

Conversation

@ascpixi
Copy link
Copy Markdown
Member

@ascpixi ascpixi commented Feb 21, 2026

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 tasks
  • client: interfaces between the server and the browser
  • worker: encodes timelapses and applies edit lists

A non-exhaustive list of features this pull request will implement is as follows:

  • video editing via edit lists
  • reliable ffmpeg-powered encoding
  • draft timelapses - user can have multiple timelapses set as draft, which can be sent for processing in different times. This also means users can await the processing of a timelapse while creating another, as well as having unlimited retries in the case of failed processing.
  • file upload via S3 signed URLs, which should be more reliable as this will be handled by Cloudflare on production
  • decoupling the canonical client from the server - custom clients can be developed via the @hackclub/lapse-api package
  • better unit tests for server and worker

Do note that this PR forces the API to move URLs from https://lapse.hackclub.com to https://api.lapse.hackclub.com.

@ascpixi
Copy link
Copy Markdown
Member Author

ascpixi commented Mar 1, 2026

Non-exhaustive to-do list:

  • Display "failed processing" alert
  • Implement draft timelapse page (with editor, ask for hackatime right away)

@ascpixi
Copy link
Copy Markdown
Member Author

ascpixi commented Mar 3, 2026

Resolves #1, #22, #63, #81, #83, #85, #103.

Comment on lines +124 to +129
const response = await fetch(targetUrl, {
method,
headers: getProxyHeaders(req),
body,
redirect: "manual",
});

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.

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 URL instance for proxyBaseUrl (e.g., const allowedBase = new URL(proxyBaseUrl);).
    • A comparison that targetUrl.origin === allowedBase.origin.
    • A comparison that targetUrl.pathname.startsWith(allowedBase.pathname) (and ensure allowedBase.pathname ends with / and matches what getProxyBaseUrl() returns).
  • 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.

Suggested changeset 1
apps/client/src/pages/api/rest/[...endpoint].ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/client/src/pages/api/rest/[...endpoint].ts b/apps/client/src/pages/api/rest/[...endpoint].ts
--- a/apps/client/src/pages/api/rest/[...endpoint].ts
+++ b/apps/client/src/pages/api/rest/[...endpoint].ts
@@ -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);
 
EOF
@@ -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);

Copilot is powered by AI and may make mistakes. Always verify output.

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

Cross-site scripting vulnerability due to
user-provided value
.

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).
  • Optionally, strip leading/trailing whitespace.
  • If the value fails these checks or is absent, return null so 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.

Suggested changeset 1
apps/client/src/pages/auth.tsx

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/client/src/pages/auth.tsx b/apps/client/src/pages/auth.tsx
--- a/apps/client/src/pages/auth.tsx
+++ b/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) {
EOF
@@ -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) {
Copilot is powered by AI and may make mistakes. Always verify output.

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

Untrusted URL redirection depends on a
user-provided value
.

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 /.
  • In initOAuth, instead of storing redirectParam directly, call sanitizeRedirect(redirectParam) and store that value.
  • In exchangeToken, after reading from sessionStorage, pass it through sanitizeRedirect before calling router.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.

Suggested changeset 1
apps/client/src/pages/auth.tsx

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/client/src/pages/auth.tsx b/apps/client/src/pages/auth.tsx
--- a/apps/client/src/pages/auth.tsx
+++ b/apps/client/src/pages/auth.tsx
@@ -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 });
EOF
@@ -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 });
Copilot is powered by AI and may make mistakes. Always verify output.
@ascpixi ascpixi marked this pull request as ready for review March 10, 2026 12:28
@ascpixi ascpixi merged commit 989ef38 into main Mar 10, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment