Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 195 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
name: CI

on:
pull_request:
push:
branches:
- main
- hardening/**

permissions:
contents: read

jobs:
versions:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python scripts/check-versions.py

server:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ClipCascade_Server/ClipCascade_Backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- run: sh ./mvnw test
- run: sh ./mvnw -DskipTests package
- run: docker build -t clipcascade-server:test .
# Pinned action rather than `curl .../main/install.sh | sh`: that piped an
# unpinned script from a moving branch straight into a shell with repo
# checkout on disk.
- name: Generate CycloneDX SBOM (server JAR)
uses: anchore/sbom-action@v0
with:
path: ClipCascade_Server/ClipCascade_Backend
format: cyclonedx-json
artifact-name: sbom-server.cdx.json
output-file: sbom-server.cdx.json
- name: Upload SBOM
uses: actions/upload-artifact@v4
with:
name: sbom-server
path: sbom-server.cdx.json

compose:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ClipCascade_Server/docker-compose
steps:
- uses: actions/checkout@v4
- run: CLIPCASCADE_IMAGE=clipcascade:test CC_INITIAL_ADMIN_PASSWORD=dummy CC_SERVER_DB_PASSWORD="dummy-file dummy-user" docker compose -f docker-compose.yml config --quiet
- run: CLIPCASCADE_IMAGE=clipcascade:test CC_INITIAL_ADMIN_PASSWORD=dummy CC_SERVER_DB_PASSWORD="dummy-file dummy-user" docker compose -f docker-compose-limitless-data-transfer.yml config --quiet
- run: CLIPCASCADE_IMAGE=clipcascade:test CC_INITIAL_ADMIN_PASSWORD=dummy CC_SERVER_DB_PASSWORD="dummy-file dummy-user" ACTIVEMQ_IMAGE=apache/activemq-classic:6.1.4 ACTIVEMQ_ADMIN_LOGIN=dummy ACTIVEMQ_ADMIN_PASSWORD=dummy docker compose -f docker-compose-stomp-external-broker.yml config --quiet
- run: CLIPCASCADE_IMAGE=clipcascade:test POSTGRES_IMAGE=postgres:16 POSTGRES_PASSWORD=dummy CC_INITIAL_ADMIN_PASSWORD=dummy docker compose -f docker-compose-multi-users.yml config --quiet
# The two files an operator actually follows during migration. Previously
# unvalidated, so a typo in either only surfaced on the live server.
- run: docker compose -f docker-compose.migrate-from-latest.yml --env-file .env.migrate.example config --quiet
- run: CLIPCASCADE_IMAGE=clipcascade:test CC_INITIAL_ADMIN_PASSWORD=dummy CC_SERVER_DB_PASSWORD="dummy-file dummy-user" CC_ALLOWED_ORIGINS=http://100.64.0.1:8080 CC_BIND_ADDRESS=100.64.0.1 docker compose -f docker-compose.yml -f docker-compose.tailscale.yml config --quiet
# Compose merges `ports` by appending, so an overlay that re-declares a
# port leaves the base binding live too. Assert exactly one binding, on
# the address we asked for.
- name: Tailscale overlay must publish only on the Tailscale address
run: |
rendered=$(CLIPCASCADE_IMAGE=clipcascade:test CC_INITIAL_ADMIN_PASSWORD=dummy \
CC_SERVER_DB_PASSWORD="dummy-file dummy-user" \
CC_ALLOWED_ORIGINS=http://100.64.0.1:8080 CC_BIND_ADDRESS=100.64.0.1 \
docker compose -f docker-compose.yml -f docker-compose.tailscale.yml config)
bindings=$(echo "$rendered" | grep -c 'target: 8080')
echo "$rendered" | grep -q 'host_ip: 100.64.0.1' \
|| { echo "expected a bind on 100.64.0.1"; exit 1; }
[ "$bindings" -eq 1 ] \
|| { echo "expected exactly 1 published port, got $bindings"; echo "$rendered"; exit 1; }
# Every shipped topology, not just the two that were checked before —
# three siblings kept publishing on 0.0.0.0 while this guard stayed green.
- name: No compose file may publish on every interface
run: |
export CLIPCASCADE_IMAGE=clipcascade:test CC_INITIAL_ADMIN_PASSWORD=dummy \
CC_SERVER_DB_PASSWORD="dummy-file dummy-user" \
ACTIVEMQ_IMAGE=apache/activemq-classic:6.1.4 ACTIVEMQ_ADMIN_LOGIN=dummy \
ACTIVEMQ_ADMIN_PASSWORD=dummy POSTGRES_IMAGE=postgres:16 POSTGRES_PASSWORD=dummy
status=0
for f in docker-compose.yml docker-compose-multi-users.yml \
docker-compose-limitless-data-transfer.yml \
docker-compose-stomp-external-broker.yml; do
rendered=$(docker compose -f "$f" config) || { echo "$f: config failed"; status=1; continue; }
if ! echo "$rendered" | grep -q 'host_ip: 127.0.0.1'; then
echo "$f: does not default to a loopback bind"
echo "$rendered" | grep -A4 'ports:'
status=1
fi
done
exit $status

mobile:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ClipCascade_Mobile/src
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: ClipCascade_Mobile/src/package-lock.json
- run: npm ci
- run: npm run lint
- run: npm test -- --watchAll=false
- run: npm audit --omit=dev --audit-level=high

mobile-android:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ClipCascade_Mobile/src/android
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: ClipCascade_Mobile/src/package-lock.json
- name: Install JS deps (autolinking)
working-directory: ClipCascade_Mobile/src
run: npm ci
- name: Set up Android SDK
uses: android-actions/setup-android@v3
# gradle.properties is gitignored (it holds the upload-key passwords for
# release builds), so a fresh checkout has none and app/build.gradle fails
# at configuration time on `hermesEnabled`. Debug builds need no secrets.
- name: Provide gradle.properties
run: cp gradle.properties.example gradle.properties
- name: Assemble debug APK
run: |
chmod +x ./gradlew
./gradlew assembleDebug --no-daemon

desktop:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ClipCascade_Desktop/src
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install --upgrade pip build
- run: python -m pip install keyring pycryptodome
- run: python -m compileall .
- run: python -m unittest discover -s tests -v
- run: python -m build
- name: Dependency audit (pip)
run: |
python -m pip install pip-audit
python -m pip_audit -r requirements_linux.txt || true

container-scan:
runs-on: ubuntu-latest
needs: server
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
# `needs: server` only orders the jobs; it does not share a workspace, so
# the jar the Dockerfile COPYs has to be built here too.
- name: Build server image
working-directory: ClipCascade_Server/ClipCascade_Backend
run: |
sh ./mvnw -q -B -DskipTests package
docker build -t clipcascade-server:test .
# 0.28.0 does not exist; the tags are v-prefixed. The job failed at
# "Set up job" on every run, so the scan never actually ran.
- name: Trivy vulnerability scan
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: clipcascade-server:test
format: table
exit-code: "0"
severity: CRITICAL,HIGH
12 changes: 11 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
private/
screenshots/
.DS_Store
.vscode/
.vscode/
.cursor/
.env
*.env
!*.env.example
ClipCascade_Server/ClipCascade_Backend/database/
ClipCascade_Server/ClipCascade_Backend/logs/
ClipCascade_Server/docker-compose/cc_users/
ClipCascade_Server/docker-compose/logs/
.venv-ci/
.venv*/
42 changes: 38 additions & 4 deletions ClipCascade_Desktop/src/cli/tray.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,11 +337,18 @@ def _on_download(self, files):
CustomDialog(
f"Saving files to: {target_directory}", msg_type="info"
).mainloop()
# Save each file to the chosen directory
# Save each file to the chosen directory (hard byte cap before write)
size_limit = self._download_size_limit()
total_written = 0
for filename, file_obj in files.items():
file_path = os.path.join(target_directory, filename)
with open(file_path, "wb") as f:
f.write(file_obj.getvalue())
data = file_obj.getvalue()
total_written += len(data)
if total_written > size_limit:
raise ValueError(
f"Download aborted: total size exceeds limit of {size_limit} bytes"
)
file_path = self._unique_download_path(target_directory, filename)
self._write_download_file(file_path, data)
logging.debug(f"Saved: {file_path}")
CustomDialog("Done.", msg_type="success").mainloop()

Expand All @@ -353,6 +360,33 @@ def _on_download(self, files):
msg_type="error",
).mainloop()

def _download_size_limit(self) -> int:
local_limit = self.config.data.get("max_clipboard_size_local_limit_bytes")
server_limit = self.config.data.get("maxsize")
if local_limit is not None and local_limit > 0:
return int(local_limit)
if server_limit is not None and server_limit > 0:
return int(server_limit)
return MAX_SIZE

@staticmethod
def _unique_download_path(target_directory, filename):
safe_name = os.path.basename(str(filename).replace("\\", "/"))
root, ext = os.path.splitext(safe_name)
candidate = os.path.join(target_directory, safe_name)
counter = 1
while os.path.exists(candidate):
candidate = os.path.join(target_directory, f"{root}_{counter}{ext}")
counter += 1
return candidate

@staticmethod
def _write_download_file(file_path, data):
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
fd = os.open(file_path, flags, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(data)

def _on_logoff(self):
try:
if self.on_logoff_callback:
Expand Down
59 changes: 58 additions & 1 deletion ClipCascade_Desktop/src/clipboard/clipboard_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from core.constants import *
from core.config import Config

Image.MAX_IMAGE_PIXELS = 25_000_000

if PLATFORM.startswith(LINUX) and LINUX_USE_CLI_UI:
from cli.tray import TaskbarPanel
else:
Expand Down Expand Up @@ -178,10 +180,14 @@ def base64_to_clipboard(self, base64_string: str, type_: str = "text"):
if self.is_clipboard_size_within_limit(txt, type_):
self.paste(txt, type_)
elif type_ == "image":
if not self.is_inbound_base64_size_within_limit(base64_string):
return
img = ClipboardManager.convert_base64_to_image(base64_img=base64_string)
if self.is_clipboard_size_within_limit(img, type_):
self.paste(img, type_)
elif type_ == "files":
if not self.is_inbound_files_size_within_limit(base64_string):
return
file_objects = ClipboardManager.convert_base64_to_files(
base64_json=base64_string
)
Expand All @@ -190,6 +196,29 @@ def base64_to_clipboard(self, base64_string: str, type_: str = "text"):
except Exception as e:
logging.error(f"Failed to convert base64 data to clipboard: {e}")

def _incoming_size_limit(self) -> int:
local_limit = self.config.data.get("max_clipboard_size_local_limit_bytes")
server_limit = self.config.data.get("maxsize")
if local_limit is not None and local_limit > 0:
return int(local_limit)
if server_limit is not None and server_limit > 0:
return int(server_limit)
return MAX_SIZE

def is_inbound_base64_size_within_limit(self, base64_string: str) -> bool:
return ClipboardManager.calculate_base64_decoded_length(base64_string) <= self._incoming_size_limit()

def is_inbound_files_size_within_limit(self, base64_json: str) -> bool:
try:
total_size = 0
for encoded_content in json.loads(base64_json).values():
total_size += ClipboardManager.calculate_base64_decoded_length(encoded_content)
if total_size > self._incoming_size_limit():
return False
return True
except Exception:
return False

@staticmethod
def execute_command(*args, input_data):
"""
Expand Down Expand Up @@ -357,6 +386,30 @@ def convert_files_to_base64(file_paths: tuple | list) -> str:

return json.dumps(base64_encoded_files)

@staticmethod
def calculate_base64_decoded_length(base64_str: str) -> int:
if not isinstance(base64_str, str):
return 0
padding = len(base64_str) - len(base64_str.rstrip("="))
return max(0, (len(base64_str) * 3) // 4 - padding)

@staticmethod
def sanitize_received_filename(file_name: str, used_names: set) -> str:
safe_name = os.path.basename(str(file_name).replace("\\", "/")).strip()
if not safe_name or safe_name in {".", ".."}:
raise ValueError("Invalid received filename")
if any(ord(char) < 32 for char in safe_name):
raise ValueError("Received filename contains control characters")

root, ext = os.path.splitext(safe_name)
candidate = safe_name
counter = 1
while candidate in used_names:
candidate = f"{root}_{counter}{ext}"
counter += 1
used_names.add(candidate)
return candidate

@staticmethod
def convert_base64_to_files(base64_json: dict) -> dict:
"""
Expand All @@ -371,9 +424,13 @@ def convert_base64_to_files(base64_json: dict) -> dict:
file_objects = {}
try:
base64_data = json.loads(base64_json)
used_names = set()
for file_name, encoded_content in base64_data.items():
safe_name = ClipboardManager.sanitize_received_filename(
file_name, used_names
)
decoded_content = base64.b64decode(encoded_content)
file_objects[file_name] = io.BytesIO(decoded_content)
file_objects[safe_name] = io.BytesIO(decoded_content)
except Exception as e:
raise IOError(f"Error processing base64 JSON. {e}") from e

Expand Down
Loading