From d1d5e0df1ee868247f93053f0b6f1c7544d736f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 00:36:04 +0000 Subject: [PATCH 1/3] Initial plan From 3d6c7e02bef3c0815939f2445986229be19c2b0e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 00:43:01 +0000 Subject: [PATCH 2/3] security: fix SSRF, file size DoS, PermissionError shadowing, and CI/CD supply chain gaps Co-authored-by: numbers-official <181934381+numbers-official@users.noreply.github.com> --- .github/workflows/release.yml | 66 ++++++++++++++++- python/numbersprotocol_capture/__init__.py | 2 + python/numbersprotocol_capture/client.py | 66 +++++++++++++++-- python/numbersprotocol_capture/errors.py | 9 ++- python/numbersprotocol_capture/types.py | 5 +- ts/src/client.ts | 84 +++++++++++++++++++++- ts/src/types.ts | 4 +- 7 files changed, 222 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8defd7c..62eb6f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,11 +48,48 @@ jobs: echo "Version $VERSION verified in both SDKs" + # Run tests before publishing + test: + name: Run Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: ts/package-lock.json + + - name: Install TypeScript dependencies + run: npm ci + working-directory: ts + + - name: Run TypeScript tests + run: npm test + working-directory: ts + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install Python dependencies + run: pip install -e ".[dev]" + working-directory: python + + - name: Run Python tests + run: pytest -v + working-directory: python + # Publish TypeScript to npm publish-npm: name: Publish to npm - needs: validate + needs: [validate, test] runs-on: ubuntu-latest + outputs: + checksum: ${{ steps.checksum.outputs.value }} defaults: run: working-directory: ts @@ -73,6 +110,14 @@ jobs: - name: Build run: npm run build + - name: Compute package checksum + id: checksum + run: | + npm pack + CHECKSUM=$(sha256sum *.tgz | awk '{print $1}') + echo "value=$CHECKSUM" >> $GITHUB_OUTPUT + echo "npm package SHA256: $CHECKSUM" + - name: Publish to npm run: npm publish --access public env: @@ -81,8 +126,10 @@ jobs: # Publish Python to PyPI publish-pypi: name: Publish to PyPI - needs: validate + needs: [validate, test] runs-on: ubuntu-latest + outputs: + checksum: ${{ steps.checksum.outputs.value }} defaults: run: working-directory: python @@ -102,6 +149,13 @@ jobs: - name: Build package run: python -m build + - name: Compute package checksums + id: checksum + run: | + CHECKSUM=$(sha256sum dist/* | awk '{print $1 " " $2}' | tr '\n' '; ') + echo "value=$CHECKSUM" >> $GITHUB_OUTPUT + echo "Python package SHA256: $CHECKSUM" + - name: Publish to PyPI env: TWINE_USERNAME: __token__ @@ -119,7 +173,8 @@ jobs: - uses: actions/checkout@v4 - name: Create Release - uses: softprops/action-gh-release@v1 + # Pinned to commit SHA for supply-chain security (tag: v1) + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 with: name: v${{ needs.validate.outputs.version }} body: | @@ -137,7 +192,12 @@ jobs: pip install numbersprotocol-capture-sdk==${{ needs.validate.outputs.version }} ``` + ### Artifact Checksums (SHA256) + - npm: `${{ needs.publish-npm.outputs.checksum }}` + - Python: `${{ needs.publish-pypi.outputs.checksum }}` + ### Links - [npm package](https://www.npmjs.com/package/@numbersprotocol/capture-sdk) - [PyPI package](https://pypi.org/project/numbersprotocol-capture-sdk/) generate_release_notes: true + diff --git a/python/numbersprotocol_capture/__init__.py b/python/numbersprotocol_capture/__init__.py index 8df5a14..dc4c237 100644 --- a/python/numbersprotocol_capture/__init__.py +++ b/python/numbersprotocol_capture/__init__.py @@ -17,6 +17,7 @@ from .errors import ( AuthenticationError, CaptureError, + ForbiddenError, InsufficientFundsError, NetworkError, NotFoundError, @@ -63,6 +64,7 @@ # Errors "CaptureError", "AuthenticationError", + "ForbiddenError", "PermissionError", "NotFoundError", "InsufficientFundsError", diff --git a/python/numbersprotocol_capture/client.py b/python/numbersprotocol_capture/client.py index 529391a..b1d7566 100644 --- a/python/numbersprotocol_capture/client.py +++ b/python/numbersprotocol_capture/client.py @@ -6,9 +6,10 @@ import json import mimetypes +import re from pathlib import Path from typing import Any -from urllib.parse import urlencode +from urllib.parse import urlencode, urlparse import httpx @@ -31,6 +32,7 @@ ) DEFAULT_BASE_URL = "https://api.numbersprotocol.io/api/v3" +DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MB HISTORY_API_URL = "https://e23hi68y55.execute-api.us-east-1.amazonaws.com/default/get-commits-storage-backend-jade-near" MERGE_TREE_API_URL = "https://us-central1-numbers-protocol-api.cloudfunctions.net/get-full-asset-tree" ASSET_SEARCH_API_URL = "https://us-central1-numbers-protocol-api.cloudfunctions.net/asset-search" @@ -65,9 +67,38 @@ def _get_mime_type(filename: str) -> str: return mime_type or "application/octet-stream" +def _is_private_or_localhost(hostname: str) -> bool: + """Returns True if the hostname is localhost or a private/link-local address.""" + if hostname in ("localhost", "127.0.0.1", "::1", "0.0.0.0"): + return True + private_ranges = [ + r"^10\.\d+\.\d+\.\d+$", + r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$", + r"^192\.168\.\d+\.\d+$", + r"^169\.254\.\d+\.\d+$", # Link-local (e.g., AWS metadata service) + ] + return any(re.match(pattern, hostname) for pattern in private_ranges) + + +def _validate_base_url(url: str) -> None: + """Validates that a custom base_url is safe (HTTPS, not localhost/private).""" + try: + parsed = urlparse(url) + except Exception as e: + raise ValidationError(f"Invalid base_url: {url}") from e + if parsed.scheme != "https": + raise ValidationError("base_url must use HTTPS protocol") + hostname = parsed.hostname or "" + if _is_private_or_localhost(hostname): + raise ValidationError( + "base_url must not point to localhost or private network addresses" + ) + + def _normalize_file( file_input: FileInput, options: RegisterOptions | None = None, + max_file_size: int | None = None, ) -> tuple[bytes, str, str]: """ Normalizes various file input types to a common format. @@ -80,6 +111,13 @@ def _normalize_file( path = Path(file_input) if not path.exists(): raise ValidationError(f"File not found: {file_input}") + if max_file_size and max_file_size > 0: + file_size = path.stat().st_size + if file_size > max_file_size: + raise ValidationError( + f"File size ({file_size} bytes) exceeds maximum allowed size " + f"({max_file_size} bytes)" + ) data = path.read_bytes() filename = path.name mime_type = _get_mime_type(filename) @@ -89,6 +127,13 @@ def _normalize_file( if isinstance(file_input, Path): if not file_input.exists(): raise ValidationError(f"File not found: {file_input}") + if max_file_size and max_file_size > 0: + file_size = file_input.stat().st_size + if file_size > max_file_size: + raise ValidationError( + f"File size ({file_size} bytes) exceeds maximum allowed size " + f"({max_file_size} bytes)" + ) data = file_input.read_bytes() filename = file_input.name mime_type = _get_mime_type(filename) @@ -98,6 +143,11 @@ def _normalize_file( if isinstance(file_input, bytes | bytearray): if not options or not options.filename: raise ValidationError("filename is required for binary input") + if max_file_size and max_file_size > 0 and len(file_input) > max_file_size: + raise ValidationError( + f"File size ({len(file_input)} bytes) exceeds maximum allowed size " + f"({max_file_size} bytes)" + ) data = bytes(file_input) filename = options.filename mime_type = _get_mime_type(filename) @@ -134,6 +184,7 @@ def __init__( *, testnet: bool = False, base_url: str | None = None, + max_file_size: int | None = None, options: CaptureOptions | None = None, ): """ @@ -142,20 +193,27 @@ def __init__( Args: token: Authentication token for API access. testnet: Use testnet environment (default: False). - base_url: Custom base URL (overrides testnet setting). + base_url: Custom base URL (overrides testnet setting). Must use HTTPS + and must not point to localhost or private network addresses. + max_file_size: Maximum file size in bytes (default: 100 MB). Set to 0 to disable. options: CaptureOptions object (alternative to individual args). """ if options: token = options.token testnet = options.testnet base_url = options.base_url + max_file_size = options.max_file_size if not token: raise ValidationError("token is required") + if base_url: + _validate_base_url(base_url) + self._token = token self._testnet = testnet self._base_url = base_url or DEFAULT_BASE_URL + self._max_file_size = max_file_size if max_file_size is not None else DEFAULT_MAX_FILE_SIZE self._client = httpx.Client(timeout=30.0) def __enter__(self) -> Capture: @@ -281,7 +339,7 @@ def register( raise ValidationError("headline must be 25 characters or less") # Normalize file input - data, file_name, mime_type = _normalize_file(file, options) + data, file_name, mime_type = _normalize_file(file, options, self._max_file_size) if len(data) == 0: raise ValidationError("file cannot be empty") @@ -665,7 +723,7 @@ def search_asset( elif options.nid: form_data["nid"] = options.nid elif options.file: - data, filename, mime_type = _normalize_file(options.file) + data, filename, mime_type = _normalize_file(options.file, max_file_size=self._max_file_size) files_data = {"file": (filename, data, mime_type)} # Add optional parameters diff --git a/python/numbersprotocol_capture/errors.py b/python/numbersprotocol_capture/errors.py index f198ede..0ba1c0c 100644 --- a/python/numbersprotocol_capture/errors.py +++ b/python/numbersprotocol_capture/errors.py @@ -32,13 +32,18 @@ def __init__(self, message: str = "Invalid or missing authentication token"): super().__init__(message, "AUTHENTICATION_ERROR", 401) -class PermissionError(CaptureError): +class ForbiddenError(CaptureError): """Thrown when user lacks permission for the requested operation.""" def __init__(self, message: str = "Insufficient permissions for this operation"): super().__init__(message, "PERMISSION_ERROR", 403) +# Backwards-compatibility alias. New code should use ForbiddenError. +# This alias avoids shadowing Python's built-in PermissionError (an OSError subclass). +PermissionError = ForbiddenError + + class NotFoundError(CaptureError): """Thrown when the requested asset is not found.""" @@ -82,7 +87,7 @@ def create_api_error( elif status_code == 401: return AuthenticationError(message) elif status_code == 403: - return PermissionError(message) + return ForbiddenError(message) elif status_code == 404: return NotFoundError(nid) else: diff --git a/python/numbersprotocol_capture/types.py b/python/numbersprotocol_capture/types.py index 0eff64d..a3c141f 100644 --- a/python/numbersprotocol_capture/types.py +++ b/python/numbersprotocol_capture/types.py @@ -30,7 +30,10 @@ class CaptureOptions: """Use testnet environment (default: False).""" base_url: str | None = None - """Custom base URL (overrides testnet setting).""" + """Custom base URL (overrides testnet setting). Must use HTTPS and must not point to localhost or private network addresses.""" + + max_file_size: int | None = None + """Maximum file size in bytes for asset registration (default: 100 MB). Set to 0 to disable.""" @dataclass diff --git a/ts/src/client.ts b/ts/src/client.ts index c86440e..0ed347d 100644 --- a/ts/src/client.ts +++ b/ts/src/client.ts @@ -22,6 +22,7 @@ import { import { sha256, createIntegrityProof, signIntegrityProof } from './crypto.js' const DEFAULT_BASE_URL = 'https://api.numbersprotocol.io/api/v3' +const DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024 // 100 MB const HISTORY_API_URL = 'https://e23hi68y55.execute-api.us-east-1.amazonaws.com/default/get-commits-storage-backend-jade-near' const MERGE_TREE_API_URL = @@ -67,12 +68,55 @@ function isNodeEnvironment(): boolean { ) } +/** + * Returns true if the hostname is localhost or a private/link-local IP address. + */ +function isPrivateOrLocalhost(hostname: string): boolean { + if ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '::1' || + hostname === '0.0.0.0' + ) { + return true + } + // Private and link-local IPv4 ranges + const privateRanges = [ + /^10\.\d+\.\d+\.\d+$/, + /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/, + /^192\.168\.\d+\.\d+$/, + /^169\.254\.\d+\.\d+$/, // Link-local (e.g., AWS metadata service) + ] + return privateRanges.some((re) => re.test(hostname)) +} + +/** + * Validates that a custom baseUrl is safe to use (HTTPS, not localhost/private). + */ +function validateBaseUrl(url: string): void { + let parsed: URL + try { + parsed = new URL(url) + } catch { + throw new ValidationError(`Invalid baseUrl: ${url}`) + } + if (parsed.protocol !== 'https:') { + throw new ValidationError('baseUrl must use HTTPS protocol') + } + if (isPrivateOrLocalhost(parsed.hostname)) { + throw new ValidationError( + 'baseUrl must not point to localhost or private network addresses' + ) + } +} + /** * Normalizes various file input types to a common format. */ async function normalizeFile( input: FileInput, - options?: RegisterOptions + options?: RegisterOptions, + maxFileSize?: number ): Promise<{ data: Uint8Array; filename: string; mimeType: string }> { // 1. String path (Node.js only) if (typeof input === 'string') { @@ -83,6 +127,14 @@ async function normalizeFile( } const fs = await import('fs/promises') const path = await import('path') + if (maxFileSize && maxFileSize > 0) { + const stat = await fs.stat(input) + if (stat.size > maxFileSize) { + throw new ValidationError( + `File size (${stat.size} bytes) exceeds maximum allowed size (${maxFileSize} bytes)` + ) + } + } const data = await fs.readFile(input) const filename = path.basename(input) const mimeType = getMimeType(filename) @@ -91,6 +143,11 @@ async function normalizeFile( // 2. File object (Browser) if (typeof File !== 'undefined' && input instanceof File) { + if (maxFileSize && maxFileSize > 0 && input.size > maxFileSize) { + throw new ValidationError( + `File size (${input.size} bytes) exceeds maximum allowed size (${maxFileSize} bytes)` + ) + } const data = new Uint8Array(await input.arrayBuffer()) return { data, filename: input.name, mimeType: input.type || getMimeType(input.name) } } @@ -100,6 +157,11 @@ async function normalizeFile( if (!options?.filename) { throw new ValidationError('filename is required for Blob input') } + if (maxFileSize && maxFileSize > 0 && input.size > maxFileSize) { + throw new ValidationError( + `File size (${input.size} bytes) exceeds maximum allowed size (${maxFileSize} bytes)` + ) + } const data = new Uint8Array(await input.arrayBuffer()) const mimeType = input.type || getMimeType(options.filename) return { data, filename: options.filename, mimeType } @@ -112,8 +174,18 @@ async function normalizeFile( // Handle both Buffer and Uint8Array let data: Uint8Array if (input instanceof Uint8Array) { + if (maxFileSize && maxFileSize > 0 && input.byteLength > maxFileSize) { + throw new ValidationError( + `File size (${input.byteLength} bytes) exceeds maximum allowed size (${maxFileSize} bytes)` + ) + } data = input } else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(input)) { + if (maxFileSize && maxFileSize > 0 && input.byteLength > maxFileSize) { + throw new ValidationError( + `File size (${input.byteLength} bytes) exceeds maximum allowed size (${maxFileSize} bytes)` + ) + } data = new Uint8Array(input.buffer, input.byteOffset, input.byteLength) } else { // This shouldn't happen with proper type checking, but handle it gracefully @@ -142,6 +214,7 @@ export class Capture { private readonly token: string private readonly baseUrl: string private readonly testnet: boolean + private readonly maxFileSize: number constructor(options: CaptureOptions) { if (!options.token) { @@ -149,7 +222,12 @@ export class Capture { } this.token = options.token this.testnet = options.testnet ?? false + if (options.baseUrl) { + validateBaseUrl(options.baseUrl) + } this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL + this.maxFileSize = + options.maxFileSize !== undefined ? options.maxFileSize : DEFAULT_MAX_FILE_SIZE } /** @@ -222,7 +300,7 @@ export class Capture { } // Normalize file input - const { data, filename, mimeType } = await normalizeFile(file, options) + const { data, filename, mimeType } = await normalizeFile(file, options, this.maxFileSize) if (data.length === 0) { throw new ValidationError('file cannot be empty') @@ -501,7 +579,7 @@ export class Capture { } else if (options.nid) { formData.append('nid', options.nid) } else if (options.file) { - const { data, filename, mimeType } = await normalizeFile(options.file) + const { data, filename, mimeType } = await normalizeFile(options.file, undefined, this.maxFileSize) const buffer = new ArrayBuffer(data.byteLength) new Uint8Array(buffer).set(data) const blob = new Blob([buffer], { type: mimeType }) diff --git a/ts/src/types.ts b/ts/src/types.ts index bc57312..9977cba 100644 --- a/ts/src/types.ts +++ b/ts/src/types.ts @@ -16,8 +16,10 @@ export interface CaptureOptions { token: string /** Use testnet environment (default: false) */ testnet?: boolean - /** Custom base URL (overrides testnet setting) */ + /** Custom base URL (overrides testnet setting). Must use HTTPS and must not point to localhost or private network addresses. */ baseUrl?: string + /** Maximum file size in bytes for asset registration (default: 100 MB). Set to 0 to disable. */ + maxFileSize?: number } /** From 7611fdf660daf66442b30a9fe838adde9b255268 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 00:46:16 +0000 Subject: [PATCH 3/3] security: add explicit permissions to release test job (CodeQL fix) Co-authored-by: numbers-official <181934381+numbers-official@users.noreply.github.com> --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62eb6f9..23e6a3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,6 +52,8 @@ jobs: test: name: Run Tests runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v4