From 39b3e633a6c9842067bad57dadb35c8c25023906 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Sun, 19 Jul 2026 05:55:58 +0000 Subject: [PATCH] Fixes security issue: potential remote Path Traversal via File Upload --- .../app/__pycache__/__init__.cpython-312.pyc | Bin 143 -> 143 bytes .../api/__pycache__/__init__.cpython-312.pyc | Bin 147 -> 147 bytes .../__pycache__/mobile_builds.cpython-312.pyc | Bin 33976 -> 47848 bytes backend/app/api/mobile_builds.py | 261 +++++++++++------- backend/app/models.py | 18 -- backend/app/schemas.py | 11 - customers/{.env.kmtn => .env.kryz} | 2 +- .../admin-mobile-builds-tab.component.ts | 44 +++ src/app/interfaces/mobile-build.ts | 11 - src/app/services/mobile-build.service.ts | 9 +- 10 files changed, 211 insertions(+), 145 deletions(-) rename customers/{.env.kmtn => .env.kryz} (82%) diff --git a/backend/app/__pycache__/__init__.cpython-312.pyc b/backend/app/__pycache__/__init__.cpython-312.pyc index f2d8ded0f3e7974b892710f787d86571fe8b7182..3743842b653e3f85d5251980a374a5a8059c8c7e 100644 GIT binary patch delta 19 YcmeBY>}TXY&CAQh00b;uE)%(104M4M4FCWD delta 19 ZcmeBY>}TXY&CAQh00h~;9Vc?P001m~1mXYy diff --git a/backend/app/api/__pycache__/__init__.cpython-312.pyc b/backend/app/api/__pycache__/__init__.cpython-312.pyc index 6a243b514c578aac2f2c3c8273b1322dc58450bf..5d7a43b7590ae348db4055e44bd385f63efbd0af 100644 GIT binary patch delta 19 ZcmbQtIGK_AG%qg~0}!xuxlH8l001a{1Q`GT delta 19 ZcmbQtIGK_AG%qg~0}$l=cAUuF0RSx*1n&R< diff --git a/backend/app/api/__pycache__/mobile_builds.cpython-312.pyc b/backend/app/api/__pycache__/mobile_builds.cpython-312.pyc index 786bccc211cf802bf8fe08c9160a4e58ffa97068..99929089a61580a79cb260d646463bcd939bd7bf 100644 GIT binary patch literal 47848 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 J(0YYL3itv@ej(@q512}R- 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 diff --git a/backend/app/api/mobile_builds.py b/backend/app/api/mobile_builds.py index 5c60b56..5dc677a 100644 --- a/backend/app/api/mobile_builds.py +++ b/backend/app/api/mobile_builds.py @@ -23,14 +23,13 @@ from app.auth import ( ) from app.config import settings from app.database import async_session, get_session -from app.models import MobileBuildArtifact, MobileBuildRequest, MobileBuildUploadedFile, StationConfig +from app.models import MobileBuildArtifact, MobileBuildRequest, StationConfig from app.schemas import ( MobileBuildArtifactResponse, MobileBuildCreate, MobileBuildDefaultsResponse, MobileBuildPreflightResponse, MobileBuildRequestResponse, - MobileBuildUploadedFileResponse, ) from app.user_models import User @@ -41,6 +40,25 @@ _ALLOWED_KINDS = { "ios": {"certificate", "profile"}, } +_FILE_ROLE_DISPLAY = { + "android_keystore": "Android Keystore", + "android_descriptor": "Android Descriptor JSON", + "ios_certificate": "iOS Certificate (.p12)", + "ios_profile": "iOS Provisioning Profile", +} + +# Accepted extensions per file kind (platform → {kind → {extensions}}) +_ALLOWED_EXTENSIONS = { + "android": { + "keystore": {".jks", ".keystore", ".p12"}, + "descriptor": {".json"}, + }, + "ios": { + "certificate": {".p12"}, + "profile": {".mobileprovision"}, + }, +} + _RUNNING_TASKS: set[int] = set() @@ -50,6 +68,26 @@ def _utc_iso(value: datetime | None) -> str | None: return value.replace(microsecond=0).isoformat() + "Z" +def _sanitize_filename(filename: str) -> str: + """Remove directory traversal components.""" + return Path(filename).name + + +def _find_uploaded_file(request_id: int, platform: str, kind: str) -> Path | None: + """Find an uploaded signing file in the request's upload directory. + + Scans for a file matching {platform}_{kind}_{uuid}{suffix}. + Returns the first matching Path, or None if not found. + """ + upload_dir, _ = _ensure_dirs() + req_dir = upload_dir / f"request_{request_id}" + if not req_dir.is_dir(): + return None + pattern = f"{platform}_{kind}_*" + matches = list(req_dir.glob(pattern)) + return matches[0] if matches else None + + def _hash_file(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as f: @@ -66,6 +104,39 @@ def _ensure_dirs() -> tuple[Path, Path]: return upload_dir, artifact_dir +def _validate_upload(platform: str, file_kind: str, filename: str | None, data: bytes) -> None: + """Validate uploaded file extension and basic content integrity.""" + # Extension check + allowed_exts = _ALLOWED_EXTENSIONS[platform][file_kind] + suffix = Path(filename or "").suffix.lower() + if suffix not in allowed_exts: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File must have one of: {', '.join(sorted(allowed_exts))}", + ) + + # Content checks + if file_kind == "descriptor": + try: + import json + json.loads(data) + except (ValueError, UnicodeDecodeError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Android descriptor must be valid JSON.", + ) + + if file_kind == "profile": + # .mobileprovision is a DER-encoded PKCS#7 (signed Apple certificate). + # Quick heuristic: it should contain readable markers like "Provision" or "plist". + text = data.decode("latin-1", errors="ignore").lower() + if "provision" not in text and "plist" not in text: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File does not appear to be a valid iOS provisioning profile.", + ) + + def _check_mobileprovision_for_carplay(path: Path) -> bool: try: raw = path.read_bytes() @@ -149,14 +220,7 @@ async def _append_log(session: AsyncSession, request: MobileBuildRequest, line: await session.commit() -async def _load_files(session: AsyncSession, request_id: int) -> list[MobileBuildUploadedFile]: - result = await session.execute( - select(MobileBuildUploadedFile).where(MobileBuildUploadedFile.request_id == request_id) - ) - return list(result.scalars().all()) - - -def _preflight_issues(request: MobileBuildRequest, files: list[MobileBuildUploadedFile]) -> list[str]: +def _preflight_issues(request: MobileBuildRequest, request_id: int) -> list[str]: issues: list[str] = [] if not request.platform_android and not request.platform_ios: @@ -180,53 +244,29 @@ def _preflight_issues(request: MobileBuildRequest, files: list[MobileBuildUpload if request.platform_ios and not settings.MOBILE_BUILD_IOS_HOST: issues.append("iOS build host is not configured.") - grouped: dict[str, dict[str, MobileBuildUploadedFile]] = {"android": {}, "ios": {}} - for f in files: - grouped.setdefault(f.platform, {})[f.file_kind] = f - + # Check for expected files on disk if request.platform_android: - present = set(grouped.get("android", {}).keys()) - expected = _ALLOWED_KINDS["android"] - if present != expected: - missing = sorted(expected - present) - extra = sorted(present - expected) - if missing: - issues.append(f"Android requires files for kinds: {', '.join(missing)}") - if extra: - issues.append(f"Android has unsupported file kinds: {', '.join(extra)}") + for kind in _ALLOWED_KINDS["android"]: + role_key = f"android_{kind}" + display = _FILE_ROLE_DISPLAY[role_key] + if not _find_uploaded_file(request_id, "android", kind): + issues.append(f"Missing: {display}") if request.platform_ios: - present = set(grouped.get("ios", {}).keys()) - expected = _ALLOWED_KINDS["ios"] - if present != expected: - missing = sorted(expected - present) - extra = sorted(present - expected) - if missing: - issues.append(f"iOS requires files for kinds: {', '.join(missing)}") - if extra: - issues.append(f"iOS has unsupported file kinds: {', '.join(extra)}") + for kind in _ALLOWED_KINDS["ios"]: + role_key = f"ios_{kind}" + display = _FILE_ROLE_DISPLAY[role_key] + if not _find_uploaded_file(request_id, "ios", kind): + issues.append(f"Missing: {display}") - profile = grouped.get("ios", {}).get("profile") - if profile: - profile_path = Path(profile.stored_path) + profile_path = _find_uploaded_file(request_id, "ios", "profile") + if profile_path: if not _check_mobileprovision_for_carplay(profile_path): issues.append("iOS provisioning profile does not appear to include CarPlay entitlement text.") return issues -def _to_file_response(row: MobileBuildUploadedFile) -> MobileBuildUploadedFileResponse: - return MobileBuildUploadedFileResponse( - id=row.id, - request_id=row.request_id, - platform=row.platform, - file_kind=row.file_kind, - original_name=row.original_name, - size=row.size, - uploaded_at=_utc_iso(row.uploaded_at) or "", - ) - - def _to_artifact_response(row: MobileBuildArtifact) -> MobileBuildArtifactResponse: return MobileBuildArtifactResponse( id=row.id, @@ -242,14 +282,9 @@ def _to_artifact_response(row: MobileBuildArtifact) -> MobileBuildArtifactRespon async def _hydrate_request(session: AsyncSession, request: MobileBuildRequest) -> MobileBuildRequestResponse: - files_result = await session.execute( - select(MobileBuildUploadedFile).where(MobileBuildUploadedFile.request_id == request.id) - ) artifact_result = await session.execute( select(MobileBuildArtifact).where(MobileBuildArtifact.request_id == request.id) ) - - files = list(files_result.scalars().all()) artifacts = list(artifact_result.scalars().all()) return MobileBuildRequestResponse( @@ -271,7 +306,6 @@ async def _hydrate_request(session: AsyncSession, request: MobileBuildRequest) - updated_at=_utc_iso(request.updated_at) or "", started_at=_utc_iso(request.started_at), completed_at=_utc_iso(request.completed_at), - files=[_to_file_response(f) for f in files], artifacts=[_to_artifact_response(a) for a in artifacts], ) @@ -323,19 +357,21 @@ async def _copy_worker_artifact( async def _upload_files_to_worker( - files: list[MobileBuildUploadedFile], host: str + request_id: int, platform: str, host: str ) -> None: scp_prefix = _build_scp_prefix(host) remote_target = f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{settings.MOBILE_BUILD_REMOTE_APP_DIR}/" - for row in files: - local_path = Path(row.stored_path) - if not local_path.exists(): - raise RuntimeError(f"Uploaded file missing: {row.original_name}") + for kind in _ALLOWED_KINDS[platform]: + role_key = f"{platform}_{kind}" + display = _FILE_ROLE_DISPLAY[role_key] + local_path = _find_uploaded_file(request_id, platform, kind) + if not local_path or not local_path.exists(): + raise RuntimeError(f"Uploaded file missing: {display}") cmd = scp_prefix + [str(local_path), remote_target] code, out = await _run_command(cmd) if code != 0: - raise RuntimeError(f"Failed to upload file {row.original_name}: {out}") + raise RuntimeError(f"Failed to upload {display}: {out}") async def _upload_theme_to_worker( @@ -364,10 +400,8 @@ async def _run_platform_build( host: str, script_name: str, ) -> None: - files = await _load_files(session, request.id) - platform_files = [f for f in files if f.platform == platform] - await _append_log(session, request, f"[{platform}] Uploading {len(platform_files)} file(s) to worker") - await _upload_files_to_worker(platform_files, host) + await _append_log(session, request, f"[{platform}] Uploading {len(_ALLOWED_KINDS[platform])} file(s) to worker") + await _upload_files_to_worker(request.id, platform, host) # Upload theme.json to worker await _upload_theme_to_worker(session, request, host) @@ -519,21 +553,20 @@ async def _run_flutter_container_build( await _append_log(session, request, "[android] theme.json not found, skipping") # Step 5: Copy signing files into the container - files = await _load_files(session, request.id) - android_files = [f for f in files if f.platform == "android"] - - for row in android_files: - local_path = Path(row.stored_path) - if not local_path.exists(): - raise RuntimeError(f"Signing file missing: {row.original_name}") + for kind in _ALLOWED_KINDS["android"]: + role_key = f"android_{kind}" + display = _FILE_ROLE_DISPLAY[role_key] + local_path = _find_uploaded_file(request.id, "android", kind) + if not local_path or not local_path.exists(): + raise RuntimeError(f"Signing file missing: {display}") # SCP directly to build container - remote_path = f"{project_dir}/{row.original_name}" + remote_path = f"{project_dir}/{_sanitize_filename(local_path.name)}" code, out = await _scp_to_container(host, local_path, remote_path) if code != 0: - raise RuntimeError(f"Failed to SCP {row.original_name} to build container: {out}") + raise RuntimeError(f"Failed to SCP {display} to build container: {out}") - await _append_log(session, request, f"[android] Signing file {row.original_name} copied to build container") + await _append_log(session, request, f"[android] {display} copied to build container") # Step 6: Run build_whitelabel.py await _append_log(session, request, "[android] Running build_whitelabel.py...") @@ -705,7 +738,42 @@ async def get_mobile_build_request( return await _hydrate_request(session, request) -@router.post("/requests/{request_id}/files", response_model=MobileBuildUploadedFileResponse) +@router.delete("/requests/{request_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_mobile_build_request( + request_id: int, + session: AsyncSession = Depends(get_session), + _: User = Depends(get_current_admin_user), +): + """Delete a draft build request and its uploaded files.""" + result = await session.execute( + select(MobileBuildRequest).where(MobileBuildRequest.id == request_id) + ) + request = result.scalar_one_or_none() + if request is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found") + + if request.status in {"queued", "building"}: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Cannot delete a request that is in progress") + + # Clean up uploaded files on disk + upload_dir, _ = _ensure_dirs() + req_dir = upload_dir / f"request_{request_id}" + if req_dir.is_dir(): + import shutil + shutil.rmtree(req_dir, ignore_errors=True) + + # Delete artifacts first (FK cascade may not cover async) + await session.execute( + delete(MobileBuildArtifact).where(MobileBuildArtifact.request_id == request_id) + ) + # Then the request itself + await session.execute( + delete(MobileBuildRequest).where(MobileBuildRequest.id == request_id) + ) + await session.commit() + + +@router.post("/requests/{request_id}/files") async def upload_mobile_build_file( request_id: int, platform: Literal["android", "ios"] = Query(...), @@ -714,6 +782,10 @@ async def upload_mobile_build_file( session: AsyncSession = Depends(get_session), _: User = Depends(get_current_admin_user), ): + """Upload a signing file to the filesystem for a build request. + + Files are stored on disk, not in the database. + """ if file_kind not in _ALLOWED_KINDS[platform]: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -731,43 +803,32 @@ async def upload_mobile_build_file( req_dir = upload_dir / f"request_{request_id}" req_dir.mkdir(parents=True, exist_ok=True) + payload = await file.read() + + # Validate extension and content + _validate_upload(platform, file_kind, file.filename, payload) + suffix = Path(file.filename or "upload.bin").suffix local_name = f"{platform}_{file_kind}_{uuid.uuid4().hex}{suffix}" local_path = req_dir / local_name - - payload = await file.read() local_path.write_bytes(payload) sha = hashlib.sha256(payload).hexdigest() size = len(payload) - await session.execute( - delete(MobileBuildUploadedFile).where( - MobileBuildUploadedFile.request_id == request_id, - MobileBuildUploadedFile.platform == platform, - MobileBuildUploadedFile.file_kind == file_kind, - ) - ) - - row = MobileBuildUploadedFile( - request_id=request_id, - platform=platform, - file_kind=file_kind, - original_name=file.filename or local_name, - stored_path=str(local_path), - sha256=sha, - size=size, - ) - session.add(row) - request.preflight_passed = False request.preflight_report = "" request.updated_at = datetime.utcnow() await session.commit() - await session.refresh(row) - return _to_file_response(row) + return { + "platform": platform, + "file_kind": file_kind, + "display_name": _FILE_ROLE_DISPLAY[f"{platform}_{file_kind}"], + "sha256": sha, + "size": size, + } @router.post("/requests/{request_id}/preflight", response_model=MobileBuildPreflightResponse) @@ -783,8 +844,7 @@ async def preflight_mobile_build( if request is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found") - files = await _load_files(session, request_id) - issues = _preflight_issues(request, files) + issues = _preflight_issues(request, request_id) request.preflight_passed = len(issues) == 0 request.preflight_report = "\n".join(issues) @@ -807,8 +867,7 @@ async def trigger_mobile_build( if request is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found") - files = await _load_files(session, request_id) - issues = _preflight_issues(request, files) + issues = _preflight_issues(request, request_id) if issues: request.preflight_passed = False request.preflight_report = "\n".join(issues) diff --git a/backend/app/models.py b/backend/app/models.py index c936f02..8954944 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -265,24 +265,6 @@ class MobileBuildRequest(Base): completed_at = Column(DateTime, nullable=True) -class MobileBuildUploadedFile(Base): - __tablename__ = "mobile_build_uploaded_files" - - id = Column(Integer, primary_key=True, autoincrement=True) - request_id = Column(Integer, ForeignKey("mobile_build_requests.id", ondelete="CASCADE"), nullable=False, index=True) - platform = Column(String(20), nullable=False) - file_kind = Column(String(40), nullable=False) - original_name = Column(String(260), nullable=False) - stored_path = Column(String(800), nullable=False) - sha256 = Column(String(64), nullable=False) - size = Column(Integer, nullable=False) - uploaded_at = Column(DateTime, nullable=False, default=datetime.utcnow) - - __table_args__ = ( - UniqueConstraint("request_id", "platform", "file_kind", name="uq_mobile_build_file_kind"), - ) - - class MobileBuildArtifact(Base): __tablename__ = "mobile_build_artifacts" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index ada361f..3ac4a84 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -380,16 +380,6 @@ class MobileBuildCreate(BaseModel): release_profile: str = "release" -class MobileBuildUploadedFileResponse(BaseModel): - id: int - request_id: int - platform: str - file_kind: str - original_name: str - size: int - uploaded_at: str - - class MobileBuildArtifactResponse(BaseModel): id: int request_id: int @@ -421,7 +411,6 @@ class MobileBuildRequestResponse(BaseModel): updated_at: str started_at: Optional[str] completed_at: Optional[str] - files: list[MobileBuildUploadedFileResponse] artifacts: list[MobileBuildArtifactResponse] diff --git a/customers/.env.kmtn b/customers/.env.kryz similarity index 82% rename from customers/.env.kmtn rename to customers/.env.kryz index 5a152a6..0a67803 100644 --- a/customers/.env.kmtn +++ b/customers/.env.kryz @@ -1,3 +1,3 @@ -PROJECT_NAME=kmtn +PROJECT_NAME=kryz FRONTEND_PORT=4201 KMTN_CORS_ORIGINS='["http://localhost:4201", "http://localhost"]' diff --git a/src/app/admin/admin-mobile-builds-tab.component.ts b/src/app/admin/admin-mobile-builds-tab.component.ts index 2028983..460a923 100644 --- a/src/app/admin/admin-mobile-builds-tab.component.ts +++ b/src/app/admin/admin-mobile-builds-tab.component.ts @@ -163,6 +163,41 @@ export class AdminMobileBuildsTabComponent implements OnInit { return 'iOS requires both certificate and provisioning profile files.'; } + // Validate file extensions before hitting the backend + const extChecks: Array<{ file: File | null; label: string; exts: string[] }> = []; + if (this.buildAndroid) { + extChecks.push({ + file: this.androidKeystoreFile, + label: 'Android keystore', + exts: ['.jks', '.keystore', '.p12'], + }); + extChecks.push({ + file: this.androidDescriptorFile, + label: 'Android descriptor', + exts: ['.json'], + }); + } + if (this.buildIos) { + extChecks.push({ + file: this.iosCertificateFile, + label: 'iOS certificate', + exts: ['.p12'], + }); + extChecks.push({ + file: this.iosProfileFile, + label: 'iOS provisioning profile', + exts: ['.mobileprovision'], + }); + } + + for (const { file, label, exts } of extChecks) { + if (!file) continue; + const ext = '.' + file.name.split('.').pop()?.toLowerCase(); + if (!exts.includes(ext)) { + return `${label} must have one of: ${exts.join(', ')}`; + } + } + return null; } @@ -176,6 +211,7 @@ export class AdminMobileBuildsTabComponent implements OnInit { } this.mobileBuildBusy = true; + let createdRequestId: number | null = null; try { const request = await firstValueFrom(this.mobileBuildService.createRequest({ platform_android: this.buildAndroid, @@ -186,6 +222,7 @@ export class AdminMobileBuildsTabComponent implements OnInit { build_number: this.buildNumber.trim(), release_profile: this.buildReleaseProfile.trim() || 'release', })); + createdRequestId = request.id; if (this.buildAndroid && this.androidKeystoreFile && this.androidDescriptorFile) { await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'keystore', this.androidKeystoreFile)); @@ -201,6 +238,13 @@ export class AdminMobileBuildsTabComponent implements OnInit { await this.reloadMobileBuilds(); this.onMobileBuildSelected(request.id); } catch (err) { + // Clean up orphaned request if files couldn't all be uploaded + if (createdRequestId !== null) { + await firstValueFrom( + this.mobileBuildService.deleteRequest(createdRequestId), + ).catch(() => {}); // best-effort cleanup + await this.reloadMobileBuilds().catch(() => {}); + } this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to create build request.'); } finally { this.mobileBuildBusy = false; diff --git a/src/app/interfaces/mobile-build.ts b/src/app/interfaces/mobile-build.ts index 1b0cb08..57f0a43 100644 --- a/src/app/interfaces/mobile-build.ts +++ b/src/app/interfaces/mobile-build.ts @@ -8,16 +8,6 @@ export interface MobileBuildDefaults { stream_metadata_url: string; } -export interface MobileBuildUploadedFile { - id: number; - request_id: number; - platform: 'android' | 'ios'; - file_kind: string; - original_name: string; - size: number; - uploaded_at: string; -} - export interface MobileBuildArtifact { id: number; request_id: number; @@ -49,7 +39,6 @@ export interface MobileBuildRequest { updated_at: string; started_at: string | null; completed_at: string | null; - files: MobileBuildUploadedFile[]; artifacts: MobileBuildArtifact[]; } diff --git a/src/app/services/mobile-build.service.ts b/src/app/services/mobile-build.service.ts index 30cccea..4546bd2 100644 --- a/src/app/services/mobile-build.service.ts +++ b/src/app/services/mobile-build.service.ts @@ -8,7 +8,6 @@ import { MobileBuildDefaults, MobileBuildPreflightResult, MobileBuildRequest, - MobileBuildUploadedFile, ThemeStatus, } from '../interfaces/mobile-build'; @@ -35,19 +34,23 @@ export class MobileBuildService { return this.http.post(`${this.baseUrl}/requests`, payload); } + deleteRequest(id: number): Observable { + return this.http.delete(`${this.baseUrl}/requests/${id}`); + } + uploadFile( requestId: number, platform: 'android' | 'ios', fileKind: string, file: File, - ): Observable { + ): Observable<{ platform: string; file_kind: string; display_name: string; sha256: string; size: number }> { const form = new FormData(); form.append('file', file); const params = new HttpParams() .set('platform', platform) .set('file_kind', fileKind); - return this.http.post( + return this.http.post<{ platform: string; file_kind: string; display_name: string; sha256: string; size: number }>( `${this.baseUrl}/requests/${requestId}/files`, form, { params },