Compare commits

...

10 Commits

Author SHA1 Message Date
f843270aab Merge pull request 'fix: disable auto-redirects to prevent SSRF (#3)' (#15) from fix/issue-3 into main
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / docker-build (push) Waiting to run
CI / security (push) Waiting to run
CI / build-result (push) Blocked by required conditions
Reviewed-on: https://git.example.com/jarianc/NASAImageDownloader/pulls/15
2026-07-05 02:50:51 -05:00
5c23a347ad Merge pull request 'fix: validate --output path to prevent arbitrary file write (#2)' (#14) from fix/issue-2 into main
Reviewed-on: https://git.example.com/jarianc/NASAImageDownloader/pulls/14
2026-07-05 02:50:29 -05:00
e692423da2 Merge pull request 'fix: sanitize filenames to prevent path traversal (#1)' (#13) from fix/issue-1 into main
Reviewed-on: https://git.example.com/jarianc/NASAImageDownloader/pulls/13
2026-07-05 02:50:07 -05:00
fdc66efe1b fix: disable auto-redirects and validate URLs to prevent SSRF (#3)
- Add _is_safe_url() checking for private/loopback/link-local/reserved IPs
- Block internal hostnames (.local, .internal, localhost, metadata.google.internal)
- Disable allow_redirects in requests.get()
- Validate redirect target before following single hop
- Raise RuntimeError on unsafe URL or redirect
2026-07-05 07:44:27 +00:00
69dfdce4f5 fix: validate --output path to prevent arbitrary file write (#2)
- Block system-critical paths (/etc, /usr, /bin, /sbin, /boot, /dev, /proc, /sys, /)
- Resolve path before validation to defeat symlink tricks
- Exit with error message if blocked path detected
2026-07-05 07:43:52 +00:00
668f48897f fix: sanitize nasa_id and filenames to prevent path traversal (#1)
- Add sanitize_filename() to strip path separators and parent refs
- Apply sanitization to nasa_id used in metadata filenames
- Apply sanitization to image filenames derived from asset_href
- Preserve raw nasa_id for API calls to avoid breaking asset lookup
2026-07-05 07:43:07 +00:00
8e7f5bc920 Merge pull request 'CI: remove --no-cache for docker layer caching' (#12) from ci-fix-nocache into main
Reviewed-on: https://git.example.com/jarianc/NASAImageDownloader/pulls/12
2026-07-04 22:23:17 -05:00
a54d57a7c7 CI: remove --no-cache for docker layer caching 2026-07-05 03:13:09 +00:00
5acbf697fa CI: add generalized workflow 2026-07-05 02:46:34 +00:00
3b0fea6379 4 hours instead of 2. Will expose a cli param for Max Run time later 2025-10-19 21:23:36 -05:00
2 changed files with 221 additions and 12 deletions

143
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,143 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
env:
GITEA_URL: https://git.example.com
jobs:
lint:
runs-on: ubuntu-latest
container:
image: gitea-job-image
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Run ruff (Python lint)
if: always()
run: |
if [[ -f pyproject.toml ]]; then
pip3 install ruff
ruff check .
else
echo "No Python project detected, skipping ruff"
fi
- name: Run npm lint (JS/TS)
if: always()
run: |
if [[ -f package.json ]]; then
npm ci
npm run lint --if-present || true
else
echo "No Node.js project detected, skipping npm lint"
fi
test:
runs-on: ubuntu-latest
container:
image: gitea-job-image
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Run pytest (Python)
if: always()
run: |
if [[ -f pyproject.toml ]]; then
python3 -m pip install --upgrade pip
pip3 install -e ".[dev]" 2>/dev/null || pip3 install -e . 2>/dev/null || true
pip3 install pytest
pytest tests/ -v --tb=short 2>/dev/null || true
else
echo "No Python project detected, skipping pytest"
fi
- name: Run npm test (JS/TS)
if: always()
run: |
if [[ -f package.json ]]; then
npm ci
npm run test --if-present || true
else
echo "No Node.js project detected, skipping npm test"
fi
- name: Run Go tests
if: always()
run: |
if [[ -f go.mod ]]; then
go test ./...
else
echo "No Go project detected, skipping go test"
fi
docker-build:
runs-on: ubuntu-latest
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Build Docker image
if: always()
run: |
if [[ -f Dockerfile ]]; then
docker build -t $GITHUB_REPOSITORY:test .
else
echo "No Dockerfile found, skipping docker build"
fi
security:
runs-on: ubuntu-latest
container:
image: gitea-job-image
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Run bandit (Python SAST)
if: always()
run: |
if [[ -f pyproject.toml ]]; then
pip3 install bandit
bandit -r . --severity-level high --confidence-level high --exclude tests/,test_*
else
echo "No Python project detected, skipping bandit"
fi
- name: Run npm audit (JS/TS)
if: always()
run: |
if [[ -f package.json ]]; then
npm ci
npm audit --audit-level=high 2>/dev/null || echo "npm audit: vulnerabilities found (non-blocking)"
else
echo "No Node.js project detected, skipping npm audit"
fi
build-result:
needs: [lint, test, docker-build, security]
runs-on: ubuntu-latest
container:
image: gitea-job-image
if: always()
steps:
- name: Summary
run: echo "All CI checks completed"

View File

@ -42,20 +42,26 @@ Dependencies
import argparse import argparse
import datetime import datetime
import ipaddress
import json import json
import os import os
import re
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
from typing import Dict, List from typing import Dict, List
from urllib.parse import urlparse
import requests import requests
try: try:
from tqdm import tqdm from tqdm import tqdm
except ImportError: except ImportError:
def tqdm(iterable, *args, **kwargs): def tqdm(iterable, *args, **kwargs):
return iterable return iterable
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Configuration # Configuration
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -65,7 +71,7 @@ API_ASSET_URL = "https://images-api.nasa.gov/asset"
IMG_DIR = Path("images") IMG_DIR = Path("images")
META_DIR = Path("metadata") META_DIR = Path("metadata")
STATE_FILE = Path("last_run.txt") STATE_FILE = Path("last_run.txt")
MAX_RUN_TIME = 2 * 60 * 60 # 2 hours in seconds MAX_RUN_TIME = 4 * 60 * 60 # 4 hours in seconds
# Ensure the output directories exist # Ensure the output directories exist
IMG_DIR.mkdir(parents=True, exist_ok=True) IMG_DIR.mkdir(parents=True, exist_ok=True)
@ -77,7 +83,9 @@ META_DIR.mkdir(parents=True, exist_ok=True)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
"""Parse CLI arguments.""" """Parse CLI arguments."""
parser = argparse.ArgumentParser(description="Download NASA images for a date range") parser = argparse.ArgumentParser(
description="Download NASA images for a date range"
)
parser.add_argument( parser.add_argument(
"--start", "--start",
type=str, type=str,
@ -160,12 +168,42 @@ def fetch_items(start: datetime.datetime, end: datetime.datetime) -> List[Dict]:
return filtered return filtered
def _is_safe_url(url: str) -> bool:
"""Check that a URL points to a safe (non-internal) destination."""
parsed = urlparse(url)
host = parsed.hostname or ""
# Block private and reserved IP ranges
try:
addr = ipaddress.ip_address(host)
if addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved:
return False
except ValueError:
pass
# Block internal hostnames
if host.endswith((".local", ".internal", ".home.arpa")) or host in ("localhost", "metadata.google.internal"):
return False
return True
def download_file(url: str, dest: Path) -> bool: def download_file(url: str, dest: Path) -> bool:
"""Streamdownload the file at *url* to *dest* if it does not already exist.""" """Streamdownload the file at *url* to *dest* if it does not already exist."""
if dest.exists(): if dest.exists():
return False return False
if not _is_safe_url(url):
raise RuntimeError(f"Unsafe download URL blocked (SSRF protection): {url}")
try: try:
with requests.get(url, stream=True, timeout=30) as r: with requests.get(url, stream=True, timeout=30, allow_redirects=False) as r:
if r.status_code in (301, 302, 303, 307, 308):
location = r.headers.get("Location", "")
if not _is_safe_url(location):
raise RuntimeError(f"Redirect to unsafe URL blocked (SSRF protection): {location}")
# Follow single validated redirect
with requests.get(location, stream=True, timeout=30, allow_redirects=False) as r2:
r2.raise_for_status()
with dest.open("wb") as f:
for chunk in r2.iter_content(chunk_size=8192):
f.write(chunk)
else:
r.raise_for_status() r.raise_for_status()
with dest.open("wb") as f: with dest.open("wb") as f:
for chunk in r.iter_content(chunk_size=8192): for chunk in r.iter_content(chunk_size=8192):
@ -175,6 +213,16 @@ def download_file(url: str, dest: Path) -> bool:
raise RuntimeError(f"Download error for {url}: {e}") from e raise RuntimeError(f"Download error for {url}: {e}") from e
def sanitize_filename(name: str) -> str:
"""Sanitize a string for use as a filename, preventing path traversal."""
# Strip path separators and parent directory references
sanitized = re.sub(r'[\/\\:\*\?"<>|]', "_", name)
sanitized = re.sub(r"^\.{1,2}($|_)", "item_", sanitized)
# Collapse multiple underscores
sanitized = re.sub(r"_+", "_", sanitized)
return sanitized.strip(".") or "item"
def save_metadata(item: Dict, dest: Path) -> None: def save_metadata(item: Dict, dest: Path) -> None:
"""Persist the full API item as formatted JSON.""" """Persist the full API item as formatted JSON."""
with dest.open("w", encoding="utf-8") as f: with dest.open("w", encoding="utf-8") as f:
@ -187,8 +235,23 @@ def save_metadata(item: Dict, dest: Path) -> None:
def main() -> None: def main() -> None:
args = parse_args() args = parse_args()
# Resolve output base directory and override global paths # Resolve output base directory and validate it is safe
base_dir = Path(args.output).resolve() base_dir = Path(args.output).resolve()
# Prevent writing to system-critical paths
_BLOCKED_PREFIXES = {
str(Path(p).resolve())
for p in ("/etc", "/usr", "/bin", "/sbin", "/boot", "/dev", "/proc", "/sys")
}
base_str = str(base_dir)
if base_str == "/" or any(
base_str == blocked or base_str.startswith(blocked + "/")
for blocked in _BLOCKED_PREFIXES
):
print(
f"Error: --output cannot point to system directory: {base_dir}",
file=sys.stderr,
)
sys.exit(1)
global IMG_DIR, META_DIR, STATE_FILE global IMG_DIR, META_DIR, STATE_FILE
IMG_DIR = base_dir / "images" IMG_DIR = base_dir / "images"
META_DIR = base_dir / "metadata" META_DIR = base_dir / "metadata"
@ -219,12 +282,13 @@ def main() -> None:
for idx, item in enumerate(tqdm(items, desc="Downloading"), start=1): for idx, item in enumerate(tqdm(items, desc="Downloading"), start=1):
data = item.get("data", [{}])[0] data = item.get("data", [{}])[0]
nasa_id = data.get("nasa_id") or data.get("title", f"item_{idx}") raw_nasa_id = data.get("nasa_id") or data.get("title", f"item_{idx}")
nasa_id = sanitize_filename(str(raw_nasa_id))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Asset lookup: fetch highresolution image URL # Asset lookup: fetch highresolution image URL
# ------------------------------------------------------------------ # ------------------------------------------------------------------
asset_url = f"{API_ASSET_URL}/{nasa_id}" asset_url = f"{API_ASSET_URL}/{raw_nasa_id}"
try: try:
asset_resp = requests.get(asset_url, timeout=20) asset_resp = requests.get(asset_url, timeout=20)
asset_resp.raise_for_status() asset_resp.raise_for_status()
@ -238,7 +302,7 @@ def main() -> None:
print(f"[{idx}] Failed asset lookup for {nasa_id}: {e}", file=sys.stderr) print(f"[{idx}] Failed asset lookup for {nasa_id}: {e}", file=sys.stderr)
continue continue
filename = Path(asset_href).name filename = sanitize_filename(Path(asset_href).name)
image_path = IMG_DIR / filename image_path = IMG_DIR / filename
meta_path = META_DIR / f"index-{nasa_id}.json" meta_path = META_DIR / f"index-{nasa_id}.json"
@ -252,7 +316,9 @@ def main() -> None:
try: try:
save_metadata(item, meta_path) save_metadata(item, meta_path)
except Exception as e: except Exception as e:
print(f"[{idx}] Failed to write metadata for {nasa_id}: {e}", file=sys.stderr) print(
f"[{idx}] Failed to write metadata for {nasa_id}: {e}", file=sys.stderr
)
elapsed = time.time() - start_time elapsed = time.time() - start_time
if downloaded: if downloaded: