Skip to content

Commit 56eec9c

Browse files
committed
Address potential path injection issues in OFRAK server
1 parent aaf2dab commit 56eec9c

File tree

6 files changed

+260
-12
lines changed

6 files changed

+260
-12
lines changed

ofrak_core/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
1212
### Fixed
1313
- `build_image.py` uses `OFRAK_DIR` from `extra_build_args` to identify `pytest_ofrak` location for develop builds ([#657](https://github.com/redballoonsecurity/ofrak/pull/657/))
1414

15+
### Security
16+
- Adding path validation and sanitization using `werkzeug.secure_filename` and path normalization to OFRAK Server ([#664](https://github.com/redballoonsecurity/ofrak/pull/664))
17+
1518
## [3.3.0](https://github.com/redballoonsecurity/ofrak/compare/ofrak-v3.2.0...ofrak-v3.3.0) - 2025-10-03
1619

1720
### Added

ofrak_core/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@ synthol~=0.1.1
2222
tempfile312~=1.0.1
2323
typeguard~=2.13.3
2424
ubi-reader==0.8.12
25+
werkzeug==3.1.3
2526
xattr==0.10.1;platform_system!="Windows"

ofrak_core/setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ class CTypesExtension(setuptools.Extension):
9696
"tempfile312~=1.0.1",
9797
"typeguard~=2.13.3",
9898
"ubi-reader>=0.8.12",
99+
"werkzeug>=3.0.0",
99100
"xattr>=0.10.1;platform_system!='Windows'",
100101
],
101102
author="Red Balloon Security",

ofrak_core/src/ofrak/gui/server.py

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import os
1818
import webbrowser
1919
from collections import defaultdict
20+
from werkzeug.utils import secure_filename
2021
from typing import (
2122
Iterable,
2223
Optional,
@@ -1245,9 +1246,9 @@ def recurse_path_collisions(path: str, count: int) -> str:
12451246

12461247
body = await request.json()
12471248
url = body.get("url")
1248-
path = recurse_path_collisions(
1249-
os.path.join(self.projects_dir, url.split(":")[-1].split("/")[-1]), 0
1250-
)
1249+
sanitized_name = sanitize_repo_name(url)
1250+
safe_path = validate_safe_directory_path(self.projects_dir, sanitized_name)
1251+
path = recurse_path_collisions(safe_path, 0)
12511252
project = OfrakProject.clone_from_git(url, path)
12521253
self.projects.add(project)
12531254
return json_response({"id": project.session_id.hex()})
@@ -1319,9 +1320,10 @@ async def get_projects_path(self, request: Request) -> Response:
13191320
async def set_projects_path(self, request: Request) -> Response:
13201321
body = await request.json()
13211322
new_path = body["path"]
1322-
if not os.path.exists(new_path):
1323-
os.mkdir(new_path)
1324-
self.projects_dir = new_path
1323+
validated_path = os.path.normpath(os.path.abspath(new_path))
1324+
if not os.path.exists(validated_path):
1325+
os.mkdir(validated_path)
1326+
self.projects_dir = validated_path
13251327
self.projects = self._slurp_projects_from_dir()
13261328
return json_response(self.projects_dir)
13271329

@@ -1386,11 +1388,13 @@ async def get_project_by_resource_id(self, request: Request) -> Response:
13861388

13871389
def _slurp_projects_from_dir(self) -> Set:
13881390
projects = set()
1389-
if not os.path.exists(self.projects_dir):
1390-
os.makedirs(self.projects_dir)
1391-
for dir in os.listdir(self.projects_dir):
1391+
validated_projects_dir = os.path.normpath(os.path.abspath(self.projects_dir))
1392+
if not os.path.exists(validated_projects_dir):
1393+
os.makedirs(validated_projects_dir)
1394+
for dir in os.listdir(validated_projects_dir):
13921395
try:
1393-
project = OfrakProject.init_from_path(os.path.join(self.projects_dir, dir))
1396+
safe_dir = validate_safe_directory_path(validated_projects_dir, dir)
1397+
project = OfrakProject.init_from_path(safe_dir)
13941398
projects.add(project)
13951399
except Exception as e:
13961400
logging.warning(f"{dir} is in the projects directory but is not a valid project")
@@ -1758,3 +1762,48 @@ def _format_component_docstring(component: ComponentInterface) -> str:
17581762
)
17591763

17601764
return docstring
1765+
1766+
1767+
def validate_safe_directory_path(base_dir: str, user_path: str) -> str:
1768+
"""
1769+
Validate that a user-provided directory path is within the allowed base directory
1770+
1771+
:param base_dir: the base directory that paths must be within
1772+
:param user_path: the user-provided path to validate
1773+
1774+
:return: the safely resolved absolute path within the base directory
1775+
1776+
:raises ValueError: if the resolved path escapes the base directory or is invalid
1777+
1778+
"""
1779+
base_dir_resolved = os.path.realpath(os.path.abspath(base_dir))
1780+
1781+
if os.path.isabs(user_path):
1782+
target_path = os.path.realpath(user_path)
1783+
else:
1784+
target_path = os.path.realpath(os.path.join(base_dir_resolved, user_path))
1785+
1786+
if not target_path.startswith(base_dir_resolved + os.sep) and target_path != base_dir_resolved:
1787+
raise ValueError(f"Path traversal attempt detected: {user_path}")
1788+
1789+
return target_path
1790+
1791+
1792+
def sanitize_repo_name(git_url: str) -> str:
1793+
"""
1794+
Extract and sanitize repository name from a git URL
1795+
1796+
:param git_url: the git repository URL
1797+
1798+
:return: sanitized repository name safe for use as a directory name
1799+
1800+
:raises ValueError: if the URL is invalid or produces an empty name
1801+
"""
1802+
repo_name = git_url.split(":")[-1].split("/")[-1]
1803+
repo_name = repo_name.replace(".git", "")
1804+
sanitized = secure_filename(repo_name)
1805+
1806+
if not sanitized:
1807+
raise ValueError(f"Invalid repository URL: {git_url}")
1808+
1809+
return sanitized

ofrak_core/tests/unit/test_ofrak_server.py

Lines changed: 195 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@
1717
from ofrak import Analyzer, Unpacker, Modifier, Packer
1818
from ofrak.core import File
1919
from ofrak.core.entropy import DataSummaryAnalyzer
20-
from ofrak.gui.server import AiohttpOFRAKServer, start_server
20+
from ofrak.gui.server import (
21+
AiohttpOFRAKServer,
22+
start_server,
23+
validate_safe_directory_path,
24+
sanitize_repo_name,
25+
)
2126
from ofrak.model.component_filters import ComponentOrMetaFilter, ComponentTypeFilter
2227
from ofrak.service.serialization.pjson import (
2328
PJSONSerializationService,
@@ -2169,3 +2174,192 @@ async def test_get_project_by_resource_id(ofrak_client: TestClient, test_project
21692174
project_resp = await ofrak_client.get(f"/{resource_id}/get_project_by_resource_id")
21702175
project = await project_resp.json()
21712176
assert project["session_id"] == id
2177+
2178+
2179+
def test_validate_safe_directory_path_valid_relative(tmpdir):
2180+
"""
2181+
Test that valid relative paths are properly resolved within the base directory
2182+
2183+
This test verifies that:
2184+
- Relative paths are converted to absolute paths
2185+
- The resolved path stays within the base directory
2186+
- Path normalization works correctly
2187+
"""
2188+
base_dir = str(tmpdir)
2189+
result = validate_safe_directory_path(base_dir, "myproject")
2190+
expected = os.path.join(os.path.realpath(base_dir), "myproject")
2191+
assert result == expected
2192+
2193+
2194+
def test_validate_safe_directory_path_valid_nested(tmpdir):
2195+
"""
2196+
Test that nested relative paths are allowed within the base directory
2197+
2198+
This test verifies that:
2199+
- Multi-level relative paths are properly resolved
2200+
- Nested directories remain within the base directory boundary
2201+
"""
2202+
base_dir = str(tmpdir)
2203+
result = validate_safe_directory_path(base_dir, "foo/bar/baz")
2204+
expected = os.path.join(os.path.realpath(base_dir), "foo/bar/baz")
2205+
assert result == expected
2206+
2207+
2208+
def test_validate_safe_directory_path_traversal_attack(tmpdir):
2209+
"""
2210+
Test that directory traversal attacks are blocked
2211+
2212+
This test verifies that:
2213+
- Path traversal attempts using ../ are detected
2214+
- A ValueError is raised with appropriate error message
2215+
- Attackers cannot escape the base directory
2216+
"""
2217+
base_dir = str(tmpdir)
2218+
with pytest.raises(ValueError, match="Path traversal attempt detected"):
2219+
validate_safe_directory_path(base_dir, "../../../etc/passwd")
2220+
2221+
2222+
def test_validate_safe_directory_path_absolute_escape(tmpdir):
2223+
"""
2224+
Test that absolute paths outside the base directory are rejected
2225+
2226+
This test verifies that:
2227+
- Absolute paths pointing outside base directory are blocked
2228+
- A ValueError is raised for such attempts
2229+
"""
2230+
base_dir = str(tmpdir)
2231+
with pytest.raises(ValueError, match="Path traversal attempt detected"):
2232+
validate_safe_directory_path(base_dir, "/etc/passwd")
2233+
2234+
2235+
def test_validate_safe_directory_path_symlink_escape(tmpdir):
2236+
"""
2237+
Test that symlink-based directory escapes are caught
2238+
2239+
This test verifies that:
2240+
- Symlinks pointing outside the base directory are detected
2241+
- Path resolution follows symlinks to detect escapes
2242+
"""
2243+
base_dir = str(tmpdir)
2244+
symlink_path = tmpdir.join("evil_link")
2245+
os.symlink("/etc", str(symlink_path))
2246+
2247+
with pytest.raises(ValueError, match="Path traversal attempt detected"):
2248+
validate_safe_directory_path(base_dir, "evil_link")
2249+
2250+
2251+
def test_validate_safe_directory_path_same_as_base(tmpdir):
2252+
"""
2253+
Test that current directory reference returns the base directory
2254+
2255+
This test verifies that:
2256+
- Using "." as the path returns the base directory itself
2257+
- This is treated as a valid case (staying within bounds)
2258+
"""
2259+
base_dir = str(tmpdir)
2260+
result = validate_safe_directory_path(base_dir, ".")
2261+
expected = os.path.realpath(base_dir)
2262+
assert result == expected
2263+
2264+
2265+
def test_validate_safe_directory_path_double_dots_in_middle(tmpdir):
2266+
"""
2267+
Test that complex path traversal attempts are handled correctly
2268+
2269+
This test verifies that:
2270+
- Paths with .. in the middle are normalized
2271+
- The final normalized path must still be within base directory
2272+
"""
2273+
base_dir = str(tmpdir)
2274+
# This should fail because it attempts to escape
2275+
with pytest.raises(ValueError, match="Path traversal attempt detected"):
2276+
validate_safe_directory_path(base_dir, "foo/../../bar")
2277+
2278+
2279+
def test_sanitize_repo_name_https_url():
2280+
"""
2281+
Test that HTTPS git URLs are properly parsed to extract repo name
2282+
2283+
This test verifies that:
2284+
- HTTPS URLs are parsed correctly
2285+
- The .git extension is removed
2286+
- Only the repository name is returned
2287+
"""
2288+
result = sanitize_repo_name("https://github.com/redballoonsecurity/ofrak.git")
2289+
assert result == "ofrak"
2290+
2291+
2292+
def test_sanitize_repo_name_ssh_url():
2293+
"""
2294+
Test that SSH git URLs are properly parsed to extract repo name
2295+
2296+
This test verifies that:
2297+
- SSH-style URLs (git@host:path) are parsed correctly
2298+
- The repository name is extracted from the path
2299+
"""
2300+
result = sanitize_repo_name("git@github.com:redballoonsecurity/ofrak.git")
2301+
assert result == "ofrak"
2302+
2303+
2304+
def test_sanitize_repo_name_no_git_extension():
2305+
"""
2306+
Test that URLs without .git extension work correctly
2307+
2308+
This test verifies that:
2309+
- URLs without the .git suffix are handled
2310+
- The repository name is still extracted correctly
2311+
"""
2312+
result = sanitize_repo_name("https://github.com/redballoonsecurity/ofrak")
2313+
assert result == "ofrak"
2314+
2315+
2316+
def test_sanitize_repo_name_special_characters():
2317+
"""
2318+
Test that special characters are removed by secure_filename
2319+
2320+
This test verifies that:
2321+
- Path traversal characters in repo names are sanitized
2322+
- The result is safe for use as a directory name
2323+
"""
2324+
result = sanitize_repo_name("https://github.com/user/../malicious/repo.git")
2325+
# secure_filename should remove or replace dangerous characters
2326+
assert ".." not in result
2327+
assert "/" not in result
2328+
2329+
2330+
def test_sanitize_repo_name_empty_result():
2331+
"""
2332+
Test that invalid URLs that produce empty names are rejected
2333+
2334+
This test verifies that:
2335+
- URLs that result in empty repo names raise ValueError
2336+
- The error message indicates an invalid repository URL
2337+
"""
2338+
with pytest.raises(ValueError, match="Invalid repository URL"):
2339+
sanitize_repo_name("https://github.com/user/")
2340+
2341+
2342+
def test_sanitize_repo_name_only_special_chars():
2343+
"""
2344+
Test that URLs that sanitize to empty strings are rejected
2345+
2346+
This test verifies that:
2347+
- URLs containing only special characters are detected
2348+
- A ValueError is raised with appropriate message
2349+
"""
2350+
with pytest.raises(ValueError, match="Invalid repository URL"):
2351+
sanitize_repo_name("https://github.com/user/../../../.git")
2352+
2353+
2354+
def test_sanitize_repo_name_unicode():
2355+
"""
2356+
Test that unicode characters are handled by secure_filename
2357+
2358+
This test verifies that:
2359+
- Unicode characters in repository names are converted to ASCII
2360+
- The result is safe for filesystem use
2361+
"""
2362+
result = sanitize_repo_name("https://github.com/user/repö.git")
2363+
# secure_filename should convert to ASCII-safe characters
2364+
assert result.isascii()
2365+
assert len(result) > 0

ofrak_core/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
VERSION = "3.4.0rc1"
1+
VERSION = "3.4.0rc2"

0 commit comments

Comments
 (0)