POST /api/v1/uploads minted an upload key from caller input with no ownership check, no content-type allowlist, and the raw filename interpolated into the S3 key. - Ownership: a caller may only mint upload URLs for a route/activity they own, enforced via the branded loadOwnedRoute/loadOwnedActivity (404 on miss, no existence leak). Closes writing into another user's resource key-space. - Content type: the request schema now constrains contentType to an image/gpx allowlist, so active content (HTML/SVG/JS) that would execute if served inline is rejected at the request boundary. - Filename: sanitizeUploadFilename() reduces the client filename to a safe basename (charset-restricted, no path components, no leading dots, length-bounded) before it becomes part of the key. Tests: schema allow/deny + filename sanitization in @trails-cool/api; handler tests covering owned-success, not-owner 404, disallowed content-type, and the route-vs-activity ownership branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
53 lines
2.1 KiB
TypeScript
53 lines
2.1 KiB
TypeScript
import type { Route } from "./+types/api.v1.uploads";
|
|
import { requireApiUser, apiError } from "~/lib/api-guard.server";
|
|
import {
|
|
PresignedUploadRequestSchema,
|
|
sanitizeUploadFilename,
|
|
ERROR_CODES,
|
|
zodIssuesToFieldErrors,
|
|
} from "@trails-cool/api";
|
|
import { randomUUID } from "node:crypto";
|
|
import { loadOwnedRoute, loadOwnedActivity } from "~/lib/ownership.server";
|
|
|
|
const S3_ENDPOINT = process.env.S3_ENDPOINT ?? "http://localhost:3902";
|
|
const S3_BUCKET = process.env.S3_BUCKET ?? "trails-cool";
|
|
const S3_PUBLIC_URL = process.env.S3_PUBLIC_URL ?? S3_ENDPOINT;
|
|
|
|
/** POST /api/v1/uploads — generate presigned upload URL */
|
|
export async function action({ request }: Route.ActionArgs) {
|
|
if (request.method !== "POST") return new Response(null, { status: 405 });
|
|
const user = await requireApiUser(request);
|
|
|
|
const body = await request.json().catch(() => null);
|
|
// The schema enforces a content-type allowlist (no active content that
|
|
// could execute if served inline) and a bounded filename.
|
|
const parsed = PresignedUploadRequestSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed",
|
|
zodIssuesToFieldErrors(parsed.error));
|
|
}
|
|
|
|
const { filename, resourceType, resourceId } = parsed.data;
|
|
|
|
// Authorization: a caller may only mint upload URLs for a route or
|
|
// activity they own. Without this, anyone could write objects into the
|
|
// key-space of another user's resource. 404 (not 403) so a guessed id
|
|
// doesn't reveal the resource exists.
|
|
const owned = resourceType === "route"
|
|
? await loadOwnedRoute(resourceId, user.id)
|
|
: await loadOwnedActivity(resourceId, user.id);
|
|
if (!owned.ok) {
|
|
return apiError(404, ERROR_CODES.NOT_FOUND, `${resourceType} not found`);
|
|
}
|
|
|
|
// Sanitize the client filename before it becomes part of the S3 key.
|
|
const safeName = sanitizeUploadFilename(filename);
|
|
const key = `${resourceType}/${resourceId}/${randomUUID()}-${safeName}`;
|
|
|
|
// Return the upload URL and the final public URL
|
|
return Response.json({
|
|
uploadUrl: `${S3_ENDPOINT}/${S3_BUCKET}/${key}`,
|
|
publicUrl: `${S3_PUBLIC_URL}/${S3_BUCKET}/${key}`,
|
|
key,
|
|
});
|
|
}
|