safety.ts 2.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
  1. /**
  2. * Security validation utilities for Studio API.
  3. * Ported from PR #97 (PapainTea) — ReDoS prevention, path traversal, port parsing.
  4. */
  5. const SAFE_UPLOAD_FILE_ID = /^[A-Za-z0-9-]{1,64}$/;
  6. const MAX_IMPORT_PATTERN_LENGTH = 160;
  7. const SAFE_IMPORT_PATTERN = /^[\p{L}\p{N}\s[\]\\.^$*+?{}\-,。::、_]+$/u;
  8. /** Validates fileId against whitelist to prevent path traversal. */
  9. export function isSafeUploadFileId(fileId: string): boolean {
  10. return typeof fileId === "string" && SAFE_UPLOAD_FILE_ID.test(fileId);
  11. }
  12. /** Validates bookId — blocks traversal sequences and null bytes. */
  13. export function isSafeBookId(bookId: string): boolean {
  14. return (
  15. typeof bookId === "string"
  16. && bookId.length > 0
  17. && bookId.trim() === bookId
  18. && bookId !== "."
  19. && bookId !== ".."
  20. && !bookId.includes("..")
  21. && !/[\\/\0]/.test(bookId)
  22. );
  23. }
  24. /**
  25. * Builds a regex from user-provided pattern with ReDoS prevention.
  26. * Blocks grouping, alternation, backreferences, and lookahead/behind.
  27. */
  28. export function buildImportRegex(pattern: string): RegExp {
  29. const normalized = String(pattern ?? "").trim();
  30. if (!normalized) {
  31. throw new Error("Import pattern is required");
  32. }
  33. if (normalized.length > MAX_IMPORT_PATTERN_LENGTH) {
  34. throw new Error(`Import pattern too long (max ${MAX_IMPORT_PATTERN_LENGTH} chars)`);
  35. }
  36. if (/[()|]/.test(normalized) || /\\[1-9]/.test(normalized) || normalized.includes("(?")) {
  37. throw new Error("Import pattern uses unsafe regex features");
  38. }
  39. if (!SAFE_IMPORT_PATTERN.test(normalized)) {
  40. throw new Error("Import pattern is invalid");
  41. }
  42. try {
  43. return new RegExp(normalized, "g");
  44. } catch {
  45. throw new Error("Import pattern is invalid");
  46. }
  47. }
  48. /** Strips filesystem paths from upload responses — only returns relative/safe fields. */
  49. export function createUploadResponse(input: {
  50. readonly fileId: string;
  51. readonly size: number;
  52. readonly chapterCount: number;
  53. readonly firstTitle: string;
  54. readonly totalChars: number;
  55. }): {
  56. readonly ok: true;
  57. readonly fileId: string;
  58. readonly size: number;
  59. readonly chapterCount: number;
  60. readonly firstTitle: string;
  61. readonly totalChars: number;
  62. } {
  63. return {
  64. ok: true,
  65. fileId: input.fileId,
  66. size: input.size,
  67. chapterCount: input.chapterCount,
  68. firstTitle: input.firstTitle,
  69. totalChars: input.totalChars,
  70. };
  71. }
  72. /** Parses port from environment with safe fallback. */
  73. export function resolveServerPort(env: Record<string, string | undefined> = process.env): number {
  74. const raw = env.INKOS_STUDIO_PORT ?? env.PORT ?? "4567";
  75. const parsed = Number.parseInt(raw, 10);
  76. return Number.isFinite(parsed) && parsed > 0 ? parsed : 4567;
  77. }