Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .server-changes/sanitize-api-loader-action-leaks-sweep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
area: webapp
type: fix
---

Sweep across the remaining `apps/webapp/app/routes/api.v1.*` raw loaders/actions that previously let thrown errors propagate to Remix's default 500 serializer, which writes `error.message` into the response body. Earlier passes covered routes with leaking `catch` blocks and two specific naked routes; this pass covers the rest of the API surface that doesn't go through `createLoaderApiRoute` / `createActionApiRoute`.

Each handler now wraps its body in try/catch, re-throws `Response` instances so auth helpers' `throw json(...)` / `throw redirect(...)` pass through unchanged, logs non-Response errors, and returns `{ error: "Internal Server Error" }` 500. For routes that already had an inner try/catch covering a service call but with auth/parsing outside the try (alertChannels, batches.results, deployments.finalize, several others), an outer try/catch is added so the inner typed-error handling is preserved. The `api.v1.authorization-code.ts` catch branch was returning `error.message` verbatim — switched to a generic body.

Each route was verified locally with a synthetic-throw probe: inject `throw new Error("SYNTHETIC ...")` at the top of the catch'd try, curl the route with a dummy bearer token, confirm the response body is the generic shape and that the synthetic message is captured server-side via `logger.error`.
Comment thread
d-cs marked this conversation as resolved.
Outdated
8 changes: 8 additions & 0 deletions .server-changes/sanitize-loader-action-leaks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
area: webapp
type: fix
---

Wrap two loaders/actions that previously let thrown errors propagate to Remix's default 500 serializer, which writes `error.message` into the response body. When the underlying call (Prisma, etc.) fails, the raw error string was reaching API consumers — including the SDK, which surfaces it back to users via `TriggerApiError`. Each handler now catches non-Response errors, logs server-side, and returns a generic 500 body. `throw json(...)` / `throw redirect(...)` from auth helpers is re-thrown unchanged.
Comment thread
d-cs marked this conversation as resolved.
Outdated

Covers `api.v1.projects.$projectRef.envvars.$slug.$name.ts` (loader + action) and `resources.platform-changelogs.tsx` (loader).
136 changes: 71 additions & 65 deletions apps/webapp/app/routes/api.v1.artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,79 +13,85 @@ export async function action({ request }: ActionFunctionArgs) {
return json({ error: "Method Not Allowed" }, { status: 405 });
}

const authenticationResult = await authenticateRequest(request, {
apiKey: true,
organizationAccessToken: false,
personalAccessToken: false,
});
try {
const authenticationResult = await authenticateRequest(request, {
apiKey: true,
organizationAccessToken: false,
personalAccessToken: false,
});

if (!authenticationResult || !authenticationResult.result.ok) {
logger.info("Invalid or missing api key", { url: request.url });
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
if (!authenticationResult || !authenticationResult.result.ok) {
logger.info("Invalid or missing api key", { url: request.url });
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}

const [, rawBody] = await tryCatch(request.json());
const body = CreateArtifactRequestBody.safeParse(rawBody ?? {});
const [, rawBody] = await tryCatch(request.json());
const body = CreateArtifactRequestBody.safeParse(rawBody ?? {});

if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
}
if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
}

const { environment: authenticatedEnv } = authenticationResult.result;
const { environment: authenticatedEnv } = authenticationResult.result;

const service = new ArtifactsService();
return await service
.createArtifact(body.data.type, authenticatedEnv, body.data.contentLength)
.match(
(result) => {
return json(
{
artifactKey: result.artifactKey,
uploadUrl: result.uploadUrl,
uploadFields: result.uploadFields,
expiresAt: result.expiresAt.toISOString(),
} satisfies CreateArtifactResponseBody,
{ status: 201 }
);
},
(error) => {
switch (error.type) {
case "artifact_size_exceeds_limit": {
logger.warn("Artifact size exceeds limit", { error });
const sizeMB = parseFloat((error.contentLength / (1024 * 1024)).toFixed(1));
const limitMB = parseFloat((error.sizeLimit / (1024 * 1024)).toFixed(1));
const service = new ArtifactsService();
return await service
.createArtifact(body.data.type, authenticatedEnv, body.data.contentLength)
.match(
(result) => {
return json(
{
artifactKey: result.artifactKey,
uploadUrl: result.uploadUrl,
uploadFields: result.uploadFields,
expiresAt: result.expiresAt.toISOString(),
} satisfies CreateArtifactResponseBody,
{ status: 201 }
);
},
(error) => {
switch (error.type) {
case "artifact_size_exceeds_limit": {
logger.warn("Artifact size exceeds limit", { error });
const sizeMB = parseFloat((error.contentLength / (1024 * 1024)).toFixed(1));
const limitMB = parseFloat((error.sizeLimit / (1024 * 1024)).toFixed(1));

let errorMessage;
let errorMessage;

switch (body.data.type) {
case "deployment_context":
errorMessage = `Artifact size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB. Make sure you are in the correct directory of your Trigger.dev project. Reach out to us if you are seeing this error consistently.`;
break;
default:
body.data.type satisfies never;
errorMessage = `Artifact size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB`;
switch (body.data.type) {
case "deployment_context":
errorMessage = `Artifact size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB. Make sure you are in the correct directory of your Trigger.dev project. Reach out to us if you are seeing this error consistently.`;
break;
default:
body.data.type satisfies never;
errorMessage = `Artifact size (${sizeMB} MB) exceeds the allowed limit of ${limitMB} MB`;
}
return json(
{
error: errorMessage,
},
{ status: 400 }
);
}
case "failed_to_create_presigned_post": {
logger.error("Failed to create presigned POST", { error });
return json({ error: "Failed to generate artifact upload URL" }, { status: 500 });
}
case "artifacts_bucket_not_configured": {
logger.error("Artifacts bucket not configured", { error });
return json({ error: "Internal server error" }, { status: 500 });
}
default: {
error satisfies never;
logger.error("Failed creating artifact", { error });
return json({ error: "Internal server error" }, { status: 500 });
}
return json(
{
error: errorMessage,
},
{ status: 400 }
);
}
case "failed_to_create_presigned_post": {
logger.error("Failed to create presigned POST", { error });
return json({ error: "Failed to generate artifact upload URL" }, { status: 500 });
}
case "artifacts_bucket_not_configured": {
logger.error("Artifacts bucket not configured", { error });
return json({ error: "Internal server error" }, { status: 500 });
}
default: {
error satisfies never;
logger.error("Failed creating artifact", { error });
return json({ error: "Internal server error" }, { status: 500 });
}
}
}
);
);
} catch (error) {
if (error instanceof Response) throw error;
logger.error("Failed to create artifact", { error });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
27 changes: 17 additions & 10 deletions apps/webapp/app/routes/api.v1.auth.jwt.claims.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";

export async function action({ request }: LoaderFunctionArgs) {
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
try {
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);

if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}

const claims = {
sub: authenticationResult.environment.id,
pub: true,
};
const claims = {
sub: authenticationResult.environment.id,
pub: true,
};

return json(claims);
return json(claims);
} catch (error) {
if (error instanceof Response) throw error;
logger.error("Failed to read auth jwt claims", { error });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
69 changes: 38 additions & 31 deletions apps/webapp/app/routes/api.v1.auth.jwt.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { z } from "zod";
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";

Expand All @@ -14,36 +15,42 @@ const RequestBodySchema = z.object({
});

export async function action({ request }: LoaderFunctionArgs) {
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);

if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
try {
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);

if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}

const parsedBody = RequestBodySchema.safeParse(await request.json());

if (!parsedBody.success) {
return json(
{ error: "Invalid request body", issues: parsedBody.error.issues },
{ status: 400 }
);
}

const claims = {
sub: authenticationResult.environment.id,
pub: true,
...parsedBody.data.claims,
};

// Sign with the environment's current canonical key, not the raw header key,
// so JWTs minted with a revoked (grace-window) key still validate — validation
// in jwtAuth.server.ts uses environment.apiKey.
const jwt = await internal_generateJWT({
secretKey: authenticationResult.environment.apiKey,
payload: claims,
expirationTime: parsedBody.data.expirationTime ?? "1h",
});

return json({ token: jwt });
} catch (error) {
if (error instanceof Response) throw error;
logger.error("Failed to mint auth jwt", { error });
return json({ error: "Internal Server Error" }, { status: 500 });
}

const parsedBody = RequestBodySchema.safeParse(await request.json());

if (!parsedBody.success) {
return json(
{ error: "Invalid request body", issues: parsedBody.error.issues },
{ status: 400 }
);
}

const claims = {
sub: authenticationResult.environment.id,
pub: true,
...parsedBody.data.claims,
};

// Sign with the environment's current canonical key, not the raw header key,
// so JWTs minted with a revoked (grace-window) key still validate — validation
// in jwtAuth.server.ts uses environment.apiKey.
const jwt = await internal_generateJWT({
secretKey: authenticationResult.environment.apiKey,
payload: claims,
expirationTime: parsedBody.data.expirationTime ?? "1h",
});

return json({ token: jwt });
}
2 changes: 1 addition & 1 deletion apps/webapp/app/routes/api.v1.authorization-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export async function action({ request }: ActionFunctionArgs) {
error: error.message,
});

return json({ error: error.message }, { status: 400 });
return json({ error: "Failed to create authorization code" }, { status: 400 });
}

return json({ error: "Something went wrong" }, { status: 500 });
Expand Down
44 changes: 25 additions & 19 deletions apps/webapp/app/routes/api.v1.batches.$batchParam.results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,38 @@ const ParamsSchema = z.object({
});

export async function loader({ request, params }: LoaderFunctionArgs) {
// Authenticate the request
const authenticationResult = await authenticateApiRequest(request);
try {
// Authenticate the request
const authenticationResult = await authenticateApiRequest(request);

if (!authenticationResult) {
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
}
if (!authenticationResult) {
return json({ error: "Invalid or Missing API Key" }, { status: 401 });
}

const parsed = ParamsSchema.safeParse(params);
const parsed = ParamsSchema.safeParse(params);

if (!parsed.success) {
return json({ error: "Invalid or missing run ID" }, { status: 400 });
}
if (!parsed.success) {
return json({ error: "Invalid or missing run ID" }, { status: 400 });
}

const { batchParam } = parsed.data;
const { batchParam } = parsed.data;

try {
const presenter = new ApiBatchResultsPresenter();
const result = await presenter.call(batchParam, authenticationResult.environment);
try {
const presenter = new ApiBatchResultsPresenter();
const result = await presenter.call(batchParam, authenticationResult.environment);

if (!result) {
return json({ error: "Batch not found" }, { status: 404 });
}
if (!result) {
return json({ error: "Batch not found" }, { status: 404 });
}

return json(result);
return json(result);
} catch (error) {
logger.error("Failed to load batch results", { error });
return json({ error: "Something went wrong, please try again." }, { status: 500 });
}
} catch (error) {
logger.error("Failed to load batch results", { error });
return json({ error: "Something went wrong, please try again." }, { status: 500 });
if (error instanceof Response) throw error;
logger.error("Failed to load batch results (outer)", { error });
return json({ error: "Internal Server Error" }, { status: 500 });
}
}
Loading