(input: ContainerRunRequest | null)
| 326 | const commands = [...(input.commands ?? []), ...(input.verify ?? [])]; |
| 327 | if (commands.length > 4) return false; |
| 328 | if (commands.some((cmd) => !/^(echo |test |pwd$|ls( |$)|node --version$|npm --version$|pnpm --version$|bun --version$)/.test(cmd))) return false; |
| 329 | // Reject shell metacharacters that could chain into other commands. Demo runs |
| 330 | // are sandboxed to a curated allow-list; block obvious injection attempts so |
| 331 | // a permissive prefix like `echo ` cannot smuggle additional commands. |
| 332 | // A single `echo ... > HANDOFF.md` is allowed so the hosted demo can create |
| 333 | // the artifact it returns, but arbitrary redirection remains blocked. |
| 334 | if (commands.some((cmd) => /[;&|`$\n\r\\]/.test(cmd))) return false; |
| 335 | if (commands.some((cmd) => /[<>]/.test(cmd) && !/^echo [A-Za-z0-9 _.,:-]+ > HANDOFF\.md$/.test(cmd))) return false; |
| 336 | return !!input.repo && /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/?$/.test(input.repo); |
| 337 | } |
| 338 | |
| 339 | function validateRun(input: ContainerRunRequest | null): Response | null { |
| 340 | if (!input || typeof input !== "object") return jsonErrorResponse(400, "bad_run", "expected JSON body"); |
| 341 | if (!input.repo || typeof input.repo !== "string") return jsonErrorResponse(400, "bad_run", "repo is required"); |
| 342 | const isGithub = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/?$/.test(input.repo); |
| 343 | const isGitlab = /^https:\/\/gitlab\.cfdata\.org\/[A-Za-z0-9_./-]+(?:\.git)?$/.test(input.repo); |
| 344 | if (!isGithub && !(input.auth === "gitlab" && isGitlab)) return jsonErrorResponse(400, "bad_run", "repo must be a public GitHub URL or a gitlab.cfdata.org URL with auth=gitlab"); |
| 345 | if (input.auth !== undefined && input.auth !== "none" && input.auth !== "gitlab") return jsonErrorResponse(400, "bad_run", "auth must be none or gitlab"); |
| 346 | if (input.clone !== undefined && input.clone !== "shallow" && input.clone !== "blobless") return jsonErrorResponse(400, "bad_run", "clone must be shallow or blobless"); |
| 347 | if (input.sparse !== undefined && (!Array.isArray(input.sparse) || input.sparse.length > 64 || input.sparse.some((path) => typeof path !== "string" || !path || path.length > 240 || path.startsWith("/") || path.split("/").includes("..") || /[\n\r`$\\]/.test(path)))) return jsonErrorResponse(400, "bad_run", "sparse must be safe relative paths"); |
| 348 | if (input.ref !== undefined && (typeof input.ref !== "string" || input.ref.length > 120 || /[^A-Za-z0-9_./-]/.test(input.ref))) return jsonErrorResponse(400, "bad_run", "ref must be a short git ref"); |
| 349 | for (const key of ["commands", "verify"] as const) { |
| 350 | const list = input[key]; |
| 351 | if (list !== undefined && !Array.isArray(list)) return jsonErrorResponse(400, "bad_run", `${key} must be an array`); |
| 352 | if (list && list.length > 12) return jsonErrorResponse(400, "bad_run", `${key} has too many commands`); |
| 353 | if (list?.some((cmd) => typeof cmd !== "string" || cmd.length > 1_000)) return jsonErrorResponse(400, "bad_run", `${key} contains an invalid command`); |
| 354 | } |
| 355 | if (!input.commands?.length && !input.verify?.length) return jsonErrorResponse(400, "bad_run", "at least one command or verify command is required"); |
| 356 | if (input.artifact !== undefined && (typeof input.artifact !== "string" || input.artifact.length > 240)) return jsonErrorResponse(400, "bad_run", "artifact must be a short relative path"); |
no test coverage detected