From da296034db45c2545f4c4a8f70a1d67ca1783c08 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:01:09 +0100 Subject: [PATCH 01/28] throw when copying from dirs other than context dir --- .changeset/silver-groups-beam.md | 6 +++++ packages/js-sdk/src/template/index.ts | 17 +++++++++++- .../js-sdk/tests/template/stacktrace.test.ts | 26 ++++++++++++++++++- packages/python-sdk/e2b/template/main.py | 24 ++++++++++++++++- .../tests/async/template_async/test_build.py | 11 -------- .../async/template_async/test_stacktrace.py | 22 ++++++++++++++++ .../sync/template_sync/test_stacktrace.py | 22 ++++++++++++++++ 7 files changed, 114 insertions(+), 14 deletions(-) create mode 100644 .changeset/silver-groups-beam.md diff --git a/.changeset/silver-groups-beam.md b/.changeset/silver-groups-beam.md new file mode 100644 index 0000000000..e171e7dab5 --- /dev/null +++ b/.changeset/silver-groups-beam.md @@ -0,0 +1,6 @@ +--- +'@e2b/python-sdk': patch +'e2b': patch +--- + +throw when copying paths outside of the context dir diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts index 177ffc1310..12f8882e6b 100644 --- a/packages/js-sdk/src/template/index.ts +++ b/packages/js-sdk/src/template/index.ts @@ -1,7 +1,7 @@ import type { PathLike } from 'node:fs' import { ApiClient } from '../api' import { ConnectionConfig } from '../connectionConfig' -import { BuildError } from '../errors' +import { BuildError, FileUploadError } from '../errors' import { runtime } from '../utils' import { getBuildStatus, @@ -40,6 +40,7 @@ import { readDockerignore, readGCPServiceAccountJSON, } from './utils' +import path from 'node:path' /** * Base class for building E2B sandbox templates. @@ -357,6 +358,20 @@ export class TemplateBase const srcs = Array.isArray(src) ? src : [src] for (const src of srcs) { + // check that src is not an absolute path or a path outside of the context directory + const srcStr = src.toString() + if ( + path.isAbsolute(srcStr) || + srcStr === '..' || + srcStr.startsWith('../') || + srcStr.startsWith('..\\') + ) { + throw new FileUploadError( + `Source path ${src} is outside of the context directory.`, + getCallerFrame(STACK_TRACE_DEPTH - 1) + ) + } + const args = [ src.toString(), dest.toString(), diff --git a/packages/js-sdk/tests/template/stacktrace.test.ts b/packages/js-sdk/tests/template/stacktrace.test.ts index fc707bf687..5670257e8a 100644 --- a/packages/js-sdk/tests/template/stacktrace.test.ts +++ b/packages/js-sdk/tests/template/stacktrace.test.ts @@ -9,7 +9,7 @@ import { buildTemplateTest } from '../setup' import { randomUUID } from 'node:crypto' const __fileContent = fs.readFileSync(__filename, 'utf8') // read current file content -const nonExistentPath = '/nonexistent/path' +const nonExistentPath = './nonexistent/path' // map template alias -> failed step index const failureMap: Record = { @@ -21,6 +21,8 @@ const failureMap: Record = { fromGCPRegistry: 0, copy: undefined, copyItems: undefined, + copyWithAbsolutePath: undefined, + copyWithRelativePath: undefined, remove: 1, rename: 1, makeDir: 1, @@ -214,6 +216,28 @@ buildTemplateTest('traces on copyItems', async ({ buildTemplate }) => { }, 'copyItems') }) +buildTemplateTest( + 'traces on copy with absolute path', + async ({ buildTemplate }) => { + await expectToThrowAndCheckTrace(async () => { + let template = Template().fromBaseImage() + template = template.skipCache().copy('/absolute/path', '/tmp/dest.txt') + await buildTemplate(template, { alias: 'copyWithAbsolutePath' }) + }, 'copy') + } +) + +buildTemplateTest( + 'traces on copy with up relative path', + async ({ buildTemplate }) => { + await expectToThrowAndCheckTrace(async () => { + let template = Template().fromBaseImage() + template = template.skipCache().copy('../relative/path', '/tmp/dest.txt') + await buildTemplate(template, { alias: 'copyWithRelativePath' }) + }, 'copy') + } +) + buildTemplateTest('traces on remove', async ({ buildTemplate }) => { let template = Template().fromBaseImage() template = template.skipCache().remove(nonExistentPath) diff --git a/packages/python-sdk/e2b/template/main.py b/packages/python-sdk/e2b/template/main.py index 9d20c5fc3c..ee8ca8e594 100644 --- a/packages/python-sdk/e2b/template/main.py +++ b/packages/python-sdk/e2b/template/main.py @@ -1,9 +1,10 @@ import json +import os from typing import Dict, List, Optional, Union, Literal from pathlib import Path -from e2b.exceptions import BuildException +from e2b.exceptions import BuildException, FileUploadException from e2b.template.consts import STACK_TRACE_DEPTH, RESOLVE_SYMLINKS from e2b.template.dockerfile_parser import parse_dockerfile from e2b.template.readycmd import ReadyCmd, wait_for_file @@ -65,6 +66,27 @@ def copy( srcs = [src] if isinstance(src, (str, Path)) else src for src_item in srcs: + # check that src is not an absolute path or a path outside of the context directory + src_str = str(src_item) + if ( + os.path.isabs(src_str) + or src_str == ".." + or src_str.startswith("../") + or src_str.startswith("..\\") + ): + caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) + stack_trace = None + if caller_frame is not None: + stack_trace = TracebackType( + tb_next=None, + tb_frame=caller_frame, + tb_lasti=caller_frame.f_lasti, + tb_lineno=caller_frame.f_lineno, + ) + raise FileUploadException( + f"Source path {src_item} is outside of the context directory." + ).with_traceback(stack_trace) + args = [ str(src_item), str(dest), diff --git a/packages/python-sdk/tests/async/template_async/test_build.py b/packages/python-sdk/tests/async/template_async/test_build.py index f16da0bd4b..a755ff4ef4 100644 --- a/packages/python-sdk/tests/async/template_async/test_build.py +++ b/packages/python-sdk/tests/async/template_async/test_build.py @@ -89,14 +89,3 @@ async def test_build_template_with_resolve_symlinks(async_build, setup_test_fold ) await async_build(template) - - -@pytest.mark.skip_debug() -async def test_build_template_with_skip_cache(async_build, setup_test_folder): - template = ( - AsyncTemplate(file_context_path=setup_test_folder) - .skip_cache() - .from_image("ubuntu:22.04") - ) - - await async_build(template) diff --git a/packages/python-sdk/tests/async/template_async/test_stacktrace.py b/packages/python-sdk/tests/async/template_async/test_stacktrace.py index 7bff6ee225..69c1d8f8b4 100644 --- a/packages/python-sdk/tests/async/template_async/test_stacktrace.py +++ b/packages/python-sdk/tests/async/template_async/test_stacktrace.py @@ -23,6 +23,8 @@ "from_gcp_registry": 0, "copy": None, "copy_items": None, + "copy_with_absolute_path": None, + "copy_with_relative_path": None, "remove": 1, "rename": 1, "make_dir": 1, @@ -177,6 +179,26 @@ async def test_traces_on_copyItems(async_build): ) +@pytest.mark.skip_debug() +async def test_traces_on_copy_with_absolute_path(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().copy("/absolute/path", "/tmp/dest.txt") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="copy_with_absolute_path"), "copy" + ) + + +@pytest.mark.skip_debug() +async def test_traces_on_copy_with_relative_path(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().copy("../relative/path", "/tmp/dest.txt") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="copy_with_relative_path"), "copy" + ) + + @pytest.mark.skip_debug() async def test_traces_on_remove(async_build): template = AsyncTemplate() diff --git a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py index 02e0a97ba4..0eae001b36 100644 --- a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py +++ b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py @@ -23,6 +23,8 @@ "from_gcp_registry": 0, "copy": None, "copy_items": None, + "copy_with_absolute_path": None, + "copy_with_relative_path": None, "remove": 1, "rename": 1, "make_dir": 1, @@ -179,6 +181,26 @@ def test_traces_on_copyItems(build): ) +@pytest.mark.skip_debug() +def test_traces_on_copy_with_absolute_path(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().copy("/absolute/path", "/tmp/dest.txt") + _expect_to_throw_and_check_trace( + lambda: build(template, alias="copy_with_absolute_path"), "copy" + ) + + +@pytest.mark.skip_debug() +def test_traces_on_copy_with_relative_path(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().copy("../relative/path", "/tmp/dest.txt") + _expect_to_throw_and_check_trace( + lambda: build(template, alias="copy_with_relative_path"), "copy" + ) + + @pytest.mark.skip_debug() def test_traces_on_remove(build): template = Template() From 49e1d6308a56f5634d1e7f9d5ffcec0ea8a04d56 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:08:04 +0100 Subject: [PATCH 02/28] added path normalization --- packages/js-sdk/src/template/index.ts | 10 +++++----- packages/js-sdk/tests/template/stacktrace.test.ts | 14 ++++++++++++++ packages/python-sdk/e2b/template/main.py | 10 +++++----- .../tests/async/template_async/test_stacktrace.py | 11 +++++++++++ .../tests/sync/template_sync/test_stacktrace.py | 11 +++++++++++ 5 files changed, 46 insertions(+), 10 deletions(-) diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts index 12f8882e6b..ded18105f6 100644 --- a/packages/js-sdk/src/template/index.ts +++ b/packages/js-sdk/src/template/index.ts @@ -359,12 +359,12 @@ export class TemplateBase for (const src of srcs) { // check that src is not an absolute path or a path outside of the context directory - const srcStr = src.toString() + const normalizedSrc = path.normalize(src.toString()) if ( - path.isAbsolute(srcStr) || - srcStr === '..' || - srcStr.startsWith('../') || - srcStr.startsWith('..\\') + path.isAbsolute(normalizedSrc) || + normalizedSrc === '..' || + normalizedSrc.startsWith('../') || + normalizedSrc.startsWith('..\\') ) { throw new FileUploadError( `Source path ${src} is outside of the context directory.`, diff --git a/packages/js-sdk/tests/template/stacktrace.test.ts b/packages/js-sdk/tests/template/stacktrace.test.ts index 5670257e8a..a66634bdd3 100644 --- a/packages/js-sdk/tests/template/stacktrace.test.ts +++ b/packages/js-sdk/tests/template/stacktrace.test.ts @@ -23,6 +23,7 @@ const failureMap: Record = { copyItems: undefined, copyWithAbsolutePath: undefined, copyWithRelativePath: undefined, + copyWithEmbeddedDotDot: undefined, remove: 1, rename: 1, makeDir: 1, @@ -238,6 +239,19 @@ buildTemplateTest( } ) +buildTemplateTest( + 'traces on copy with embedded .. path', + async ({ buildTemplate }) => { + await expectToThrowAndCheckTrace(async () => { + let template = Template().fromBaseImage() + template = template + .skipCache() + .copy('assets/../../secret', '/tmp/dest.txt') + await buildTemplate(template, { alias: 'copyWithEmbeddedDotDot' }) + }, 'copy') + } +) + buildTemplateTest('traces on remove', async ({ buildTemplate }) => { let template = Template().fromBaseImage() template = template.skipCache().remove(nonExistentPath) diff --git a/packages/python-sdk/e2b/template/main.py b/packages/python-sdk/e2b/template/main.py index ee8ca8e594..47e9ea645d 100644 --- a/packages/python-sdk/e2b/template/main.py +++ b/packages/python-sdk/e2b/template/main.py @@ -67,12 +67,12 @@ def copy( for src_item in srcs: # check that src is not an absolute path or a path outside of the context directory - src_str = str(src_item) + normalized_src = os.path.normpath(str(src_item)) if ( - os.path.isabs(src_str) - or src_str == ".." - or src_str.startswith("../") - or src_str.startswith("..\\") + os.path.isabs(normalized_src) + or normalized_src == ".." + or normalized_src.startswith("../") + or normalized_src.startswith("..\\") ): caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) stack_trace = None diff --git a/packages/python-sdk/tests/async/template_async/test_stacktrace.py b/packages/python-sdk/tests/async/template_async/test_stacktrace.py index 69c1d8f8b4..a29ba2fe25 100644 --- a/packages/python-sdk/tests/async/template_async/test_stacktrace.py +++ b/packages/python-sdk/tests/async/template_async/test_stacktrace.py @@ -25,6 +25,7 @@ "copy_items": None, "copy_with_absolute_path": None, "copy_with_relative_path": None, + "copy_with_embedded_dotdot": None, "remove": 1, "rename": 1, "make_dir": 1, @@ -199,6 +200,16 @@ async def test_traces_on_copy_with_relative_path(async_build): ) +@pytest.mark.skip_debug() +async def test_traces_on_copy_with_embedded_dotdot(async_build): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().copy("assets/../../secret", "/tmp/dest.txt") + await _expect_to_throw_and_check_trace( + lambda: async_build(template, alias="copy_with_embedded_dotdot"), "copy" + ) + + @pytest.mark.skip_debug() async def test_traces_on_remove(async_build): template = AsyncTemplate() diff --git a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py index 0eae001b36..2fdcfc4830 100644 --- a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py +++ b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py @@ -25,6 +25,7 @@ "copy_items": None, "copy_with_absolute_path": None, "copy_with_relative_path": None, + "copy_with_embedded_dotdot": None, "remove": 1, "rename": 1, "make_dir": 1, @@ -201,6 +202,16 @@ def test_traces_on_copy_with_relative_path(build): ) +@pytest.mark.skip_debug() +def test_traces_on_copy_with_embedded_dotdot(build): + template = Template() + template = template.from_base_image() + template = template.skip_cache().copy("assets/../../secret", "/tmp/dest.txt") + _expect_to_throw_and_check_trace( + lambda: build(template, alias="copy_with_embedded_dotdot"), "copy" + ) + + @pytest.mark.skip_debug() def test_traces_on_remove(build): template = Template() From 6c56d9525c7081d88a23e829d036509ce01c378e Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:18:39 +0100 Subject: [PATCH 03/28] upd --- .../python-sdk/tests/async/template_async/test_stacktrace.py | 2 +- packages/python-sdk/tests/sync/template_sync/test_stacktrace.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/python-sdk/tests/async/template_async/test_stacktrace.py b/packages/python-sdk/tests/async/template_async/test_stacktrace.py index a29ba2fe25..a2b401b953 100644 --- a/packages/python-sdk/tests/async/template_async/test_stacktrace.py +++ b/packages/python-sdk/tests/async/template_async/test_stacktrace.py @@ -11,7 +11,7 @@ import e2b.template_async.main as template_async_main import e2b.template_async.build_api as build_api_mod -non_existent_path = "/nonexistent/path" +non_existent_path = "./nonexistent/path" # map template alias -> failed step index failure_map: dict[str, Optional[int]] = { diff --git a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py index 2fdcfc4830..b9f10847ff 100644 --- a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py +++ b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py @@ -11,7 +11,7 @@ import e2b.template_sync.main as template_sync_main import e2b.template_sync.build_api as build_api_mod -non_existent_path = "/nonexistent/path" +non_existent_path = "./nonexistent/path" # map template alias -> failed step index failure_map: dict[str, Optional[int]] = { From 5187988d2659fb73480c9ca253e91a07e9c075da Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:27:17 +0100 Subject: [PATCH 04/28] moved path handling to files hash (buld-time) --- packages/js-sdk/src/template/index.ts | 14 ------------- packages/js-sdk/src/template/utils.ts | 17 ++++++++++++++- .../js-sdk/tests/template/stacktrace.test.ts | 14 ++++++------- packages/python-sdk/e2b/template/main.py | 21 ------------------- packages/python-sdk/e2b/template/utils.py | 4 ++++ 5 files changed, 26 insertions(+), 44 deletions(-) diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts index ded18105f6..b851ff076b 100644 --- a/packages/js-sdk/src/template/index.ts +++ b/packages/js-sdk/src/template/index.ts @@ -358,20 +358,6 @@ export class TemplateBase const srcs = Array.isArray(src) ? src : [src] for (const src of srcs) { - // check that src is not an absolute path or a path outside of the context directory - const normalizedSrc = path.normalize(src.toString()) - if ( - path.isAbsolute(normalizedSrc) || - normalizedSrc === '..' || - normalizedSrc.startsWith('../') || - normalizedSrc.startsWith('..\\') - ) { - throw new FileUploadError( - `Source path ${src} is outside of the context directory.`, - getCallerFrame(STACK_TRACE_DEPTH - 1) - ) - } - const args = [ src.toString(), dest.toString(), diff --git a/packages/js-sdk/src/template/utils.ts b/packages/js-sdk/src/template/utils.ts index d1224b9782..18dce1efb4 100644 --- a/packages/js-sdk/src/template/utils.ts +++ b/packages/js-sdk/src/template/utils.ts @@ -106,7 +106,22 @@ export async function calculateFilesHash( resolveSymlinks: boolean, stackTrace: string | undefined ): Promise { - const srcPath = path.join(contextPath, src) + const normPath = path.normalize(src) + if ( + normPath === '..' || + normPath.startsWith('../') || + normPath.startsWith('..\\') + ) { + const error = new Error( + `Source path ${src} is outside of the context directory.` + ) + if (stackTrace) { + error.stack = stackTrace + } + throw error + } + + const srcPath = path.join(contextPath, normPath) const hash = crypto.createHash('sha256') const content = `COPY ${src} ${dest}` diff --git a/packages/js-sdk/tests/template/stacktrace.test.ts b/packages/js-sdk/tests/template/stacktrace.test.ts index a66634bdd3..a9b23cb126 100644 --- a/packages/js-sdk/tests/template/stacktrace.test.ts +++ b/packages/js-sdk/tests/template/stacktrace.test.ts @@ -220,9 +220,9 @@ buildTemplateTest('traces on copyItems', async ({ buildTemplate }) => { buildTemplateTest( 'traces on copy with absolute path', async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template.skipCache().copy('/absolute/path', '/tmp/dest.txt') await expectToThrowAndCheckTrace(async () => { - let template = Template().fromBaseImage() - template = template.skipCache().copy('/absolute/path', '/tmp/dest.txt') await buildTemplate(template, { alias: 'copyWithAbsolutePath' }) }, 'copy') } @@ -231,9 +231,9 @@ buildTemplateTest( buildTemplateTest( 'traces on copy with up relative path', async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template.skipCache().copy('../relative/path', '/tmp/dest.txt') await expectToThrowAndCheckTrace(async () => { - let template = Template().fromBaseImage() - template = template.skipCache().copy('../relative/path', '/tmp/dest.txt') await buildTemplate(template, { alias: 'copyWithRelativePath' }) }, 'copy') } @@ -242,11 +242,9 @@ buildTemplateTest( buildTemplateTest( 'traces on copy with embedded .. path', async ({ buildTemplate }) => { + let template = Template().fromBaseImage() + template = template.skipCache().copy('assets/../../secret', '/tmp/dest.txt') await expectToThrowAndCheckTrace(async () => { - let template = Template().fromBaseImage() - template = template - .skipCache() - .copy('assets/../../secret', '/tmp/dest.txt') await buildTemplate(template, { alias: 'copyWithEmbeddedDotDot' }) }, 'copy') } diff --git a/packages/python-sdk/e2b/template/main.py b/packages/python-sdk/e2b/template/main.py index 47e9ea645d..347e3d7fee 100644 --- a/packages/python-sdk/e2b/template/main.py +++ b/packages/python-sdk/e2b/template/main.py @@ -66,27 +66,6 @@ def copy( srcs = [src] if isinstance(src, (str, Path)) else src for src_item in srcs: - # check that src is not an absolute path or a path outside of the context directory - normalized_src = os.path.normpath(str(src_item)) - if ( - os.path.isabs(normalized_src) - or normalized_src == ".." - or normalized_src.startswith("../") - or normalized_src.startswith("..\\") - ): - caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) - stack_trace = None - if caller_frame is not None: - stack_trace = TracebackType( - tb_next=None, - tb_frame=caller_frame, - tb_lasti=caller_frame.f_lasti, - tb_lineno=caller_frame.f_lineno, - ) - raise FileUploadException( - f"Source path {src_item} is outside of the context directory." - ).with_traceback(stack_trace) - args = [ str(src_item), str(dest), diff --git a/packages/python-sdk/e2b/template/utils.py b/packages/python-sdk/e2b/template/utils.py index 413c58a4f7..21891d1585 100644 --- a/packages/python-sdk/e2b/template/utils.py +++ b/packages/python-sdk/e2b/template/utils.py @@ -119,6 +119,10 @@ def calculate_files_hash( :raises ValueError: If no files match the source pattern """ + # check that src is not an absolute path or a path outside of the context directory + norm_path = os.path.normpath(src) + if os.path.isabs(norm_path) or norm_path == ".." or norm_path.startswith("../") or norm_path.startswith("..\\"): + raise ValueError(f"Source path {src} is outside of the context directory.").with_traceback(stack_trace) src_path = os.path.join(context_path, src) hash_obj = hashlib.sha256() content = f"COPY {src} {dest}" From 484cbbc4303a8a7c528aaf178340be0b74fb27e0 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:28:01 +0100 Subject: [PATCH 05/28] remove unused vars --- packages/js-sdk/src/template/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts index b851ff076b..177ffc1310 100644 --- a/packages/js-sdk/src/template/index.ts +++ b/packages/js-sdk/src/template/index.ts @@ -1,7 +1,7 @@ import type { PathLike } from 'node:fs' import { ApiClient } from '../api' import { ConnectionConfig } from '../connectionConfig' -import { BuildError, FileUploadError } from '../errors' +import { BuildError } from '../errors' import { runtime } from '../utils' import { getBuildStatus, @@ -40,7 +40,6 @@ import { readDockerignore, readGCPServiceAccountJSON, } from './utils' -import path from 'node:path' /** * Base class for building E2B sandbox templates. From e02de9cc140361449d8de21458afa02c8ca87b59 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:29:17 +0100 Subject: [PATCH 06/28] remove unused imports --- packages/python-sdk/e2b/template/main.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/python-sdk/e2b/template/main.py b/packages/python-sdk/e2b/template/main.py index 347e3d7fee..9d20c5fc3c 100644 --- a/packages/python-sdk/e2b/template/main.py +++ b/packages/python-sdk/e2b/template/main.py @@ -1,10 +1,9 @@ import json -import os from typing import Dict, List, Optional, Union, Literal from pathlib import Path -from e2b.exceptions import BuildException, FileUploadException +from e2b.exceptions import BuildException from e2b.template.consts import STACK_TRACE_DEPTH, RESOLVE_SYMLINKS from e2b.template.dockerfile_parser import parse_dockerfile from e2b.template.readycmd import ReadyCmd, wait_for_file From 850df112a8c34f46d9b994ac17160ba4e4f819a1 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:30:40 +0100 Subject: [PATCH 07/28] format --- packages/python-sdk/e2b/template/utils.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/python-sdk/e2b/template/utils.py b/packages/python-sdk/e2b/template/utils.py index 21891d1585..f793384e8d 100644 --- a/packages/python-sdk/e2b/template/utils.py +++ b/packages/python-sdk/e2b/template/utils.py @@ -121,8 +121,15 @@ def calculate_files_hash( """ # check that src is not an absolute path or a path outside of the context directory norm_path = os.path.normpath(src) - if os.path.isabs(norm_path) or norm_path == ".." or norm_path.startswith("../") or norm_path.startswith("..\\"): - raise ValueError(f"Source path {src} is outside of the context directory.").with_traceback(stack_trace) + if ( + os.path.isabs(norm_path) + or norm_path == ".." + or norm_path.startswith("../") + or norm_path.startswith("..\\") + ): + raise ValueError( + f"Source path {src} is outside of the context directory." + ).with_traceback(stack_trace) src_path = os.path.join(context_path, src) hash_obj = hashlib.sha256() content = f"COPY {src} {dest}" From a6ea577e835e2f47d700a0ac9b85534470cd44b8 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:51:37 +0100 Subject: [PATCH 08/28] added isabs check --- packages/js-sdk/src/template/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/js-sdk/src/template/utils.ts b/packages/js-sdk/src/template/utils.ts index 18dce1efb4..dba952e2ee 100644 --- a/packages/js-sdk/src/template/utils.ts +++ b/packages/js-sdk/src/template/utils.ts @@ -108,6 +108,7 @@ export async function calculateFilesHash( ): Promise { const normPath = path.normalize(src) if ( + path.isAbsolute(normPath) || normPath === '..' || normPath.startsWith('../') || normPath.startsWith('..\\') From bff918b6b899ac4558ba72d6c3ba56ad5b1729a3 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:47:42 +0100 Subject: [PATCH 09/28] moved path checking to .copy --- packages/js-sdk/src/template/index.ts | 16 +++++- packages/js-sdk/src/template/utils.ts | 31 +++++------ .../js-sdk/tests/template/stacktrace.test.ts | 28 +++++++--- packages/python-sdk/e2b/template/main.py | 20 ++++++- packages/python-sdk/e2b/template/utils.py | 26 +++++---- .../async/template_async/test_stacktrace.py | 53 ++++++++++++------- .../sync/template_sync/test_stacktrace.py | 53 ++++++++++++------- 7 files changed, 155 insertions(+), 72 deletions(-) diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts index 6f202c100a..82394b1238 100644 --- a/packages/js-sdk/src/template/index.ts +++ b/packages/js-sdk/src/template/index.ts @@ -38,10 +38,12 @@ import { calculateFilesHash, getCallerDirectory, getCallerFrame, + isPathOutsideContext, padOctal, readDockerignore, readGCPServiceAccountJSON, } from './utils' +import path from 'node:path' /** * Base class for building E2B sandbox templates. @@ -375,8 +377,20 @@ export class TemplateBase const srcs = Array.isArray(src) ? src : [src] for (const src of srcs) { + const normPath = path.normalize(src.toString()) + if (isPathOutsideContext(normPath)) { + const error = new Error( + `Source path ${src} is outside of the context directory.` + ) + const stackTrace = getCallerFrame(STACK_TRACE_DEPTH - 1) + if (stackTrace) { + error.stack = stackTrace + } + throw error + } + const args = [ - src.toString(), + normPath, dest.toString(), options?.user ?? '', options?.mode ? padOctal(options.mode) : '', diff --git a/packages/js-sdk/src/template/utils.ts b/packages/js-sdk/src/template/utils.ts index dba952e2ee..65f2dfccce 100644 --- a/packages/js-sdk/src/template/utils.ts +++ b/packages/js-sdk/src/template/utils.ts @@ -106,23 +106,7 @@ export async function calculateFilesHash( resolveSymlinks: boolean, stackTrace: string | undefined ): Promise { - const normPath = path.normalize(src) - if ( - path.isAbsolute(normPath) || - normPath === '..' || - normPath.startsWith('../') || - normPath.startsWith('..\\') - ) { - const error = new Error( - `Source path ${src} is outside of the context directory.` - ) - if (stackTrace) { - error.stack = stackTrace - } - throw error - } - - const srcPath = path.join(contextPath, normPath) + const srcPath = path.join(contextPath, src) const hash = crypto.createHash('sha256') const content = `COPY ${src} ${dest}` @@ -385,3 +369,16 @@ export function readGCPServiceAccountJSON( } return JSON.stringify(pathOrContent) } + +export function isPathOutsideContext(src: string): boolean { + // Check for Windows drive letters (e.g., C:foo, D:\path). + // Drive-relative paths like 'C:foo' bypass path.isAbsolute() + const hasDriveLetter = /^[a-zA-Z]:/.test(src) + return ( + path.isAbsolute(src) || + hasDriveLetter || + src === '..' || + src.startsWith('../') || + src.startsWith('..\\') + ) +} diff --git a/packages/js-sdk/tests/template/stacktrace.test.ts b/packages/js-sdk/tests/template/stacktrace.test.ts index a9b23cb126..629071b7b6 100644 --- a/packages/js-sdk/tests/template/stacktrace.test.ts +++ b/packages/js-sdk/tests/template/stacktrace.test.ts @@ -24,6 +24,7 @@ const failureMap: Record = { copyWithAbsolutePath: undefined, copyWithRelativePath: undefined, copyWithEmbeddedDotDot: undefined, + copyWithWindowsDrivePath: undefined, remove: 1, rename: 1, makeDir: 1, @@ -220,9 +221,9 @@ buildTemplateTest('traces on copyItems', async ({ buildTemplate }) => { buildTemplateTest( 'traces on copy with absolute path', async ({ buildTemplate }) => { - let template = Template().fromBaseImage() - template = template.skipCache().copy('/absolute/path', '/tmp/dest.txt') await expectToThrowAndCheckTrace(async () => { + let template = Template().fromBaseImage() + template = template.skipCache().copy('/absolute/path', '/tmp/dest.txt') await buildTemplate(template, { alias: 'copyWithAbsolutePath' }) }, 'copy') } @@ -231,9 +232,9 @@ buildTemplateTest( buildTemplateTest( 'traces on copy with up relative path', async ({ buildTemplate }) => { - let template = Template().fromBaseImage() - template = template.skipCache().copy('../relative/path', '/tmp/dest.txt') await expectToThrowAndCheckTrace(async () => { + let template = Template().fromBaseImage() + template = template.skipCache().copy('../relative/path', '/tmp/dest.txt') await buildTemplate(template, { alias: 'copyWithRelativePath' }) }, 'copy') } @@ -242,14 +243,29 @@ buildTemplateTest( buildTemplateTest( 'traces on copy with embedded .. path', async ({ buildTemplate }) => { - let template = Template().fromBaseImage() - template = template.skipCache().copy('assets/../../secret', '/tmp/dest.txt') await expectToThrowAndCheckTrace(async () => { + let template = Template().fromBaseImage() + template = template + .skipCache() + .copy('assets/../../secret', '/tmp/dest.txt') await buildTemplate(template, { alias: 'copyWithEmbeddedDotDot' }) }, 'copy') } ) +buildTemplateTest( + 'traces on copy with Windows drive-relative path', + async ({ buildTemplate }) => { + // Test for Windows drive-relative paths (e.g., C:foo) that could bypass + // path.isAbsolute() but cause path.join() to discard the context directory + await expectToThrowAndCheckTrace(async () => { + let template = Template().fromBaseImage() + template = template.skipCache().copy('C:secret.txt', '/tmp/dest.txt') + await buildTemplate(template, { alias: 'copyWithWindowsDrivePath' }) + }, 'copy') + } +) + buildTemplateTest('traces on remove', async ({ buildTemplate }) => { let template = Template().fromBaseImage() template = template.skipCache().remove(nonExistentPath) diff --git a/packages/python-sdk/e2b/template/main.py b/packages/python-sdk/e2b/template/main.py index 9d20c5fc3c..28503a2e4b 100644 --- a/packages/python-sdk/e2b/template/main.py +++ b/packages/python-sdk/e2b/template/main.py @@ -1,6 +1,7 @@ import json from typing import Dict, List, Optional, Union, Literal from pathlib import Path +import os from e2b.exceptions import BuildException @@ -21,6 +22,7 @@ read_dockerignore, read_gcp_service_account_json, get_caller_frame, + is_path_outside_context, ) from types import TracebackType @@ -65,8 +67,24 @@ def copy( srcs = [src] if isinstance(src, (str, Path)) else src for src_item in srcs: + norm_path = os.path.normpath(str(src_item)) + if is_path_outside_context(norm_path): + caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) + stack_trace = None + if caller_frame is not None: + stack_trace = TracebackType( + tb_next=None, + tb_frame=caller_frame, + tb_lasti=caller_frame.f_lasti, + tb_lineno=caller_frame.f_lineno, + ) + + raise ValueError( + f"Source path {src_item} is outside of the context directory." + ).with_traceback(stack_trace) + args = [ - str(src_item), + norm_path, str(dest), user or "", pad_octal(mode) if mode else "", diff --git a/packages/python-sdk/e2b/template/utils.py b/packages/python-sdk/e2b/template/utils.py index f793384e8d..3ae16e57e3 100644 --- a/packages/python-sdk/e2b/template/utils.py +++ b/packages/python-sdk/e2b/template/utils.py @@ -119,17 +119,6 @@ def calculate_files_hash( :raises ValueError: If no files match the source pattern """ - # check that src is not an absolute path or a path outside of the context directory - norm_path = os.path.normpath(src) - if ( - os.path.isabs(norm_path) - or norm_path == ".." - or norm_path.startswith("../") - or norm_path.startswith("..\\") - ): - raise ValueError( - f"Source path {src} is outside of the context directory." - ).with_traceback(stack_trace) src_path = os.path.join(context_path, src) hash_obj = hashlib.sha256() content = f"COPY {src} {dest}" @@ -329,3 +318,18 @@ def read_gcp_service_account_json( return f.read() else: return json.dumps(path_or_content) + + +def is_path_outside_context(src: str) -> bool: + """ + Check if a path is outside of the context directory. + + :param src: Path to check + :return: True if the path is outside of the context directory, False otherwise + """ + return ( + os.path.isabs(src) + or src == ".." + or src.startswith("../") + or src.startswith("..\\") + ) diff --git a/packages/python-sdk/tests/async/template_async/test_stacktrace.py b/packages/python-sdk/tests/async/template_async/test_stacktrace.py index a2b401b953..24379c070c 100644 --- a/packages/python-sdk/tests/async/template_async/test_stacktrace.py +++ b/packages/python-sdk/tests/async/template_async/test_stacktrace.py @@ -26,6 +26,7 @@ "copy_with_absolute_path": None, "copy_with_relative_path": None, "copy_with_embedded_dotdot": None, + "copy_with_windows_drive_path": None, "remove": 1, "rename": 1, "make_dir": 1, @@ -182,32 +183,48 @@ async def test_traces_on_copyItems(async_build): @pytest.mark.skip_debug() async def test_traces_on_copy_with_absolute_path(async_build): - template = AsyncTemplate() - template = template.from_base_image() - template = template.skip_cache().copy("/absolute/path", "/tmp/dest.txt") - await _expect_to_throw_and_check_trace( - lambda: async_build(template, alias="copy_with_absolute_path"), "copy" - ) + async def run(): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().copy("/absolute/path", "/tmp/dest.txt") + await async_build(template, alias="copy_with_absolute_path") + + await _expect_to_throw_and_check_trace(run, "copy") @pytest.mark.skip_debug() async def test_traces_on_copy_with_relative_path(async_build): - template = AsyncTemplate() - template = template.from_base_image() - template = template.skip_cache().copy("../relative/path", "/tmp/dest.txt") - await _expect_to_throw_and_check_trace( - lambda: async_build(template, alias="copy_with_relative_path"), "copy" - ) + async def run(): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().copy("../relative/path", "/tmp/dest.txt") + await async_build(template, alias="copy_with_relative_path") + + await _expect_to_throw_and_check_trace(run, "copy") @pytest.mark.skip_debug() async def test_traces_on_copy_with_embedded_dotdot(async_build): - template = AsyncTemplate() - template = template.from_base_image() - template = template.skip_cache().copy("assets/../../secret", "/tmp/dest.txt") - await _expect_to_throw_and_check_trace( - lambda: async_build(template, alias="copy_with_embedded_dotdot"), "copy" - ) + async def run(): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().copy("assets/../../secret", "/tmp/dest.txt") + await async_build(template, alias="copy_with_embedded_dotdot") + + await _expect_to_throw_and_check_trace(run, "copy") + + +@pytest.mark.skip_debug() +async def test_traces_on_copy_with_windows_drive_path(async_build): + # Test for Windows drive-relative paths (e.g., C:foo) that could bypass + # os.path.isabs() but cause os.path.join() to discard the context directory + async def run(): + template = AsyncTemplate() + template = template.from_base_image() + template = template.skip_cache().copy("C:secret.txt", "/tmp/dest.txt") + await async_build(template, alias="copy_with_windows_drive_path") + + await _expect_to_throw_and_check_trace(run, "copy") @pytest.mark.skip_debug() diff --git a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py index b9f10847ff..7acf5f0912 100644 --- a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py +++ b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py @@ -26,6 +26,7 @@ "copy_with_absolute_path": None, "copy_with_relative_path": None, "copy_with_embedded_dotdot": None, + "copy_with_windows_drive_path": None, "remove": 1, "rename": 1, "make_dir": 1, @@ -184,32 +185,48 @@ def test_traces_on_copyItems(build): @pytest.mark.skip_debug() def test_traces_on_copy_with_absolute_path(build): - template = Template() - template = template.from_base_image() - template = template.skip_cache().copy("/absolute/path", "/tmp/dest.txt") - _expect_to_throw_and_check_trace( - lambda: build(template, alias="copy_with_absolute_path"), "copy" - ) + def run(): + template = Template() + template = template.from_base_image() + template = template.skip_cache().copy("/absolute/path", "/tmp/dest.txt") + build(template, alias="copy_with_absolute_path") + + _expect_to_throw_and_check_trace(run, "copy") @pytest.mark.skip_debug() def test_traces_on_copy_with_relative_path(build): - template = Template() - template = template.from_base_image() - template = template.skip_cache().copy("../relative/path", "/tmp/dest.txt") - _expect_to_throw_and_check_trace( - lambda: build(template, alias="copy_with_relative_path"), "copy" - ) + def run(): + template = Template() + template = template.from_base_image() + template = template.skip_cache().copy("../relative/path", "/tmp/dest.txt") + build(template, alias="copy_with_relative_path") + + _expect_to_throw_and_check_trace(run, "copy") @pytest.mark.skip_debug() def test_traces_on_copy_with_embedded_dotdot(build): - template = Template() - template = template.from_base_image() - template = template.skip_cache().copy("assets/../../secret", "/tmp/dest.txt") - _expect_to_throw_and_check_trace( - lambda: build(template, alias="copy_with_embedded_dotdot"), "copy" - ) + def run(): + template = Template() + template = template.from_base_image() + template = template.skip_cache().copy("assets/../../secret", "/tmp/dest.txt") + build(template, alias="copy_with_embedded_dotdot") + + _expect_to_throw_and_check_trace(run, "copy") + + +@pytest.mark.skip_debug() +def test_traces_on_copy_with_windows_drive_path(build): + # Test for Windows drive-relative paths (e.g., C:foo) that could bypass + # os.path.isabs() but cause os.path.join() to discard the context directory + def run(): + template = Template() + template = template.from_base_image() + template = template.skip_cache().copy("C:secret.txt", "/tmp/dest.txt") + build(template, alias="copy_with_windows_drive_path") + + _expect_to_throw_and_check_trace(run, "copy") @pytest.mark.skip_debug() From 040aef9024abc49a82ab81249eb9181d81ac1f52 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:52:27 +0100 Subject: [PATCH 10/28] normalize path only in is_path_outside_context --- packages/js-sdk/src/template/index.ts | 5 ++--- packages/js-sdk/src/template/utils.ts | 11 ++++++----- packages/python-sdk/e2b/template/main.py | 5 ++--- packages/python-sdk/e2b/template/utils.py | 9 +++++---- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts index 82394b1238..0bd8913bbb 100644 --- a/packages/js-sdk/src/template/index.ts +++ b/packages/js-sdk/src/template/index.ts @@ -377,8 +377,7 @@ export class TemplateBase const srcs = Array.isArray(src) ? src : [src] for (const src of srcs) { - const normPath = path.normalize(src.toString()) - if (isPathOutsideContext(normPath)) { + if (isPathOutsideContext(src.toString())) { const error = new Error( `Source path ${src} is outside of the context directory.` ) @@ -390,7 +389,7 @@ export class TemplateBase } const args = [ - normPath, + src.toString(), dest.toString(), options?.user ?? '', options?.mode ? padOctal(options.mode) : '', diff --git a/packages/js-sdk/src/template/utils.ts b/packages/js-sdk/src/template/utils.ts index 65f2dfccce..ecaf3d3ca9 100644 --- a/packages/js-sdk/src/template/utils.ts +++ b/packages/js-sdk/src/template/utils.ts @@ -373,12 +373,13 @@ export function readGCPServiceAccountJSON( export function isPathOutsideContext(src: string): boolean { // Check for Windows drive letters (e.g., C:foo, D:\path). // Drive-relative paths like 'C:foo' bypass path.isAbsolute() - const hasDriveLetter = /^[a-zA-Z]:/.test(src) + const normPath = path.normalize(src) + const hasDriveLetter = /^[a-zA-Z]:/.test(normPath) return ( - path.isAbsolute(src) || + path.isAbsolute(normPath) || hasDriveLetter || - src === '..' || - src.startsWith('../') || - src.startsWith('..\\') + normPath === '..' || + normPath.startsWith('../') || + normPath.startsWith('..\\') ) } diff --git a/packages/python-sdk/e2b/template/main.py b/packages/python-sdk/e2b/template/main.py index 28503a2e4b..4b3b6fd3da 100644 --- a/packages/python-sdk/e2b/template/main.py +++ b/packages/python-sdk/e2b/template/main.py @@ -67,8 +67,7 @@ def copy( srcs = [src] if isinstance(src, (str, Path)) else src for src_item in srcs: - norm_path = os.path.normpath(str(src_item)) - if is_path_outside_context(norm_path): + if is_path_outside_context(str(src_item)): caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) stack_trace = None if caller_frame is not None: @@ -84,7 +83,7 @@ def copy( ).with_traceback(stack_trace) args = [ - norm_path, + str(src_item), str(dest), user or "", pad_octal(mode) if mode else "", diff --git a/packages/python-sdk/e2b/template/utils.py b/packages/python-sdk/e2b/template/utils.py index 3ae16e57e3..a1543f7c32 100644 --- a/packages/python-sdk/e2b/template/utils.py +++ b/packages/python-sdk/e2b/template/utils.py @@ -327,9 +327,10 @@ def is_path_outside_context(src: str) -> bool: :param src: Path to check :return: True if the path is outside of the context directory, False otherwise """ + norm_path = os.path.normpath(src) return ( - os.path.isabs(src) - or src == ".." - or src.startswith("../") - or src.startswith("..\\") + os.path.isabs(norm_path) + or norm_path == ".." + or norm_path.startswith("../") + or norm_path.startswith("..\\") ) From 3c6bf6da4978830ba62ecbe40d81213e9078c9ad Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:54:42 +0100 Subject: [PATCH 11/28] lint --- packages/python-sdk/e2b/template/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/python-sdk/e2b/template/main.py b/packages/python-sdk/e2b/template/main.py index 4b3b6fd3da..a51cfc51df 100644 --- a/packages/python-sdk/e2b/template/main.py +++ b/packages/python-sdk/e2b/template/main.py @@ -1,7 +1,6 @@ import json from typing import Dict, List, Optional, Union, Literal from pathlib import Path -import os from e2b.exceptions import BuildException From 2f7de17e6a020ed4d467d33a9461bc8964f2c8e7 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:56:28 +0100 Subject: [PATCH 12/28] lint again --- packages/js-sdk/src/template/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts index 0bd8913bbb..3ddb38ef31 100644 --- a/packages/js-sdk/src/template/index.ts +++ b/packages/js-sdk/src/template/index.ts @@ -43,7 +43,6 @@ import { readDockerignore, readGCPServiceAccountJSON, } from './utils' -import path from 'node:path' /** * Base class for building E2B sandbox templates. From 1144910872517bce74df7b58fcb111823e106495 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:42:20 +0100 Subject: [PATCH 13/28] added comment --- packages/js-sdk/src/template/index.ts | 4 +++- packages/js-sdk/src/template/utils.ts | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts index 3ddb38ef31..c271d84a6e 100644 --- a/packages/js-sdk/src/template/index.ts +++ b/packages/js-sdk/src/template/index.ts @@ -376,7 +376,9 @@ export class TemplateBase const srcs = Array.isArray(src) ? src : [src] for (const src of srcs) { - if (isPathOutsideContext(src.toString())) { + if ( + isPathOutsideContext(src.toString(), this.fileContextPath.toString()) + ) { const error = new Error( `Source path ${src} is outside of the context directory.` ) diff --git a/packages/js-sdk/src/template/utils.ts b/packages/js-sdk/src/template/utils.ts index ecaf3d3ca9..7e9e5cb363 100644 --- a/packages/js-sdk/src/template/utils.ts +++ b/packages/js-sdk/src/template/utils.ts @@ -370,6 +370,12 @@ export function readGCPServiceAccountJSON( return JSON.stringify(pathOrContent) } +/** + * Check if a path is outside of the context directory. + * + * @param src Source path + * @returns True if the path is outside of the context directory, False otherwise + */ export function isPathOutsideContext(src: string): boolean { // Check for Windows drive letters (e.g., C:foo, D:\path). // Drive-relative paths like 'C:foo' bypass path.isAbsolute() From b8ac1a6f939639ebfe9e5e9749328dd70f3f6203 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:12:24 +0100 Subject: [PATCH 14/28] redo tests --- packages/js-sdk/src/template/utils.ts | 4 -- .../js-sdk/tests/template/stacktrace.test.ts | 52 ------------------- .../utils/isPathOutsideContext.test.ts | 52 +++++++++++++++++++ .../async/template_async/test_stacktrace.py | 50 ------------------ .../utils/test_is_path_outside_context.py | 40 ++++++++++++++ .../sync/template_sync/test_stacktrace.py | 50 ------------------ 6 files changed, 92 insertions(+), 156 deletions(-) create mode 100644 packages/js-sdk/tests/template/utils/isPathOutsideContext.test.ts create mode 100644 packages/python-sdk/tests/shared/template/utils/test_is_path_outside_context.py diff --git a/packages/js-sdk/src/template/utils.ts b/packages/js-sdk/src/template/utils.ts index 7e9e5cb363..905619fe05 100644 --- a/packages/js-sdk/src/template/utils.ts +++ b/packages/js-sdk/src/template/utils.ts @@ -377,13 +377,9 @@ export function readGCPServiceAccountJSON( * @returns True if the path is outside of the context directory, False otherwise */ export function isPathOutsideContext(src: string): boolean { - // Check for Windows drive letters (e.g., C:foo, D:\path). - // Drive-relative paths like 'C:foo' bypass path.isAbsolute() const normPath = path.normalize(src) - const hasDriveLetter = /^[a-zA-Z]:/.test(normPath) return ( path.isAbsolute(normPath) || - hasDriveLetter || normPath === '..' || normPath.startsWith('../') || normPath.startsWith('..\\') diff --git a/packages/js-sdk/tests/template/stacktrace.test.ts b/packages/js-sdk/tests/template/stacktrace.test.ts index 629071b7b6..661db5a00f 100644 --- a/packages/js-sdk/tests/template/stacktrace.test.ts +++ b/packages/js-sdk/tests/template/stacktrace.test.ts @@ -21,10 +21,6 @@ const failureMap: Record = { fromGCPRegistry: 0, copy: undefined, copyItems: undefined, - copyWithAbsolutePath: undefined, - copyWithRelativePath: undefined, - copyWithEmbeddedDotDot: undefined, - copyWithWindowsDrivePath: undefined, remove: 1, rename: 1, makeDir: 1, @@ -218,54 +214,6 @@ buildTemplateTest('traces on copyItems', async ({ buildTemplate }) => { }, 'copyItems') }) -buildTemplateTest( - 'traces on copy with absolute path', - async ({ buildTemplate }) => { - await expectToThrowAndCheckTrace(async () => { - let template = Template().fromBaseImage() - template = template.skipCache().copy('/absolute/path', '/tmp/dest.txt') - await buildTemplate(template, { alias: 'copyWithAbsolutePath' }) - }, 'copy') - } -) - -buildTemplateTest( - 'traces on copy with up relative path', - async ({ buildTemplate }) => { - await expectToThrowAndCheckTrace(async () => { - let template = Template().fromBaseImage() - template = template.skipCache().copy('../relative/path', '/tmp/dest.txt') - await buildTemplate(template, { alias: 'copyWithRelativePath' }) - }, 'copy') - } -) - -buildTemplateTest( - 'traces on copy with embedded .. path', - async ({ buildTemplate }) => { - await expectToThrowAndCheckTrace(async () => { - let template = Template().fromBaseImage() - template = template - .skipCache() - .copy('assets/../../secret', '/tmp/dest.txt') - await buildTemplate(template, { alias: 'copyWithEmbeddedDotDot' }) - }, 'copy') - } -) - -buildTemplateTest( - 'traces on copy with Windows drive-relative path', - async ({ buildTemplate }) => { - // Test for Windows drive-relative paths (e.g., C:foo) that could bypass - // path.isAbsolute() but cause path.join() to discard the context directory - await expectToThrowAndCheckTrace(async () => { - let template = Template().fromBaseImage() - template = template.skipCache().copy('C:secret.txt', '/tmp/dest.txt') - await buildTemplate(template, { alias: 'copyWithWindowsDrivePath' }) - }, 'copy') - } -) - buildTemplateTest('traces on remove', async ({ buildTemplate }) => { let template = Template().fromBaseImage() template = template.skipCache().remove(nonExistentPath) diff --git a/packages/js-sdk/tests/template/utils/isPathOutsideContext.test.ts b/packages/js-sdk/tests/template/utils/isPathOutsideContext.test.ts new file mode 100644 index 0000000000..68992ea6a1 --- /dev/null +++ b/packages/js-sdk/tests/template/utils/isPathOutsideContext.test.ts @@ -0,0 +1,52 @@ +import { expect, test, describe } from 'vitest' +import { isPathOutsideContext } from '../../../src/template/utils' + +describe('isPathOutsideContext', () => { + describe('absolute paths', () => { + test('should return true for Unix absolute paths', () => { + expect(isPathOutsideContext('/absolute/path')).toBe(true) + }) + }) + + describe('parent directory traversal', () => { + test('should return true for parent directory only', () => { + expect(isPathOutsideContext('..')).toBe(true) + }) + + test('should return true for paths starting with ../', () => { + expect(isPathOutsideContext('../file.txt')).toBe(true) + }) + + test('should return true for paths starting with ..\\', () => { + expect(isPathOutsideContext('..\\file.txt')).toBe(true) + }) + + test('should return true for normalized paths that escape context', () => { + expect(isPathOutsideContext('foo/../../bar')).toBe(true) + }) + }) + + describe('valid relative paths', () => { + test('should return false for simple relative paths', () => { + expect(isPathOutsideContext('file.txt')).toBe(false) + expect(isPathOutsideContext('folder/file.txt')).toBe(false) + }) + + test('should return false for current directory references', () => { + expect(isPathOutsideContext('.')).toBe(false) + expect(isPathOutsideContext('./file.txt')).toBe(false) + expect(isPathOutsideContext('./folder/file.txt')).toBe(false) + }) + + test('should return false for glob patterns', () => { + expect(isPathOutsideContext('*.txt')).toBe(false) + expect(isPathOutsideContext('**/*.ts')).toBe(false) + expect(isPathOutsideContext('src/**/*')).toBe(false) + }) + + test('should return false for hidden files and directories', () => { + expect(isPathOutsideContext('.hidden')).toBe(false) + expect(isPathOutsideContext('.config/settings')).toBe(false) + }) + }) +}) diff --git a/packages/python-sdk/tests/async/template_async/test_stacktrace.py b/packages/python-sdk/tests/async/template_async/test_stacktrace.py index 24379c070c..7d0fe1ab55 100644 --- a/packages/python-sdk/tests/async/template_async/test_stacktrace.py +++ b/packages/python-sdk/tests/async/template_async/test_stacktrace.py @@ -23,10 +23,6 @@ "from_gcp_registry": 0, "copy": None, "copy_items": None, - "copy_with_absolute_path": None, - "copy_with_relative_path": None, - "copy_with_embedded_dotdot": None, - "copy_with_windows_drive_path": None, "remove": 1, "rename": 1, "make_dir": 1, @@ -181,52 +177,6 @@ async def test_traces_on_copyItems(async_build): ) -@pytest.mark.skip_debug() -async def test_traces_on_copy_with_absolute_path(async_build): - async def run(): - template = AsyncTemplate() - template = template.from_base_image() - template = template.skip_cache().copy("/absolute/path", "/tmp/dest.txt") - await async_build(template, alias="copy_with_absolute_path") - - await _expect_to_throw_and_check_trace(run, "copy") - - -@pytest.mark.skip_debug() -async def test_traces_on_copy_with_relative_path(async_build): - async def run(): - template = AsyncTemplate() - template = template.from_base_image() - template = template.skip_cache().copy("../relative/path", "/tmp/dest.txt") - await async_build(template, alias="copy_with_relative_path") - - await _expect_to_throw_and_check_trace(run, "copy") - - -@pytest.mark.skip_debug() -async def test_traces_on_copy_with_embedded_dotdot(async_build): - async def run(): - template = AsyncTemplate() - template = template.from_base_image() - template = template.skip_cache().copy("assets/../../secret", "/tmp/dest.txt") - await async_build(template, alias="copy_with_embedded_dotdot") - - await _expect_to_throw_and_check_trace(run, "copy") - - -@pytest.mark.skip_debug() -async def test_traces_on_copy_with_windows_drive_path(async_build): - # Test for Windows drive-relative paths (e.g., C:foo) that could bypass - # os.path.isabs() but cause os.path.join() to discard the context directory - async def run(): - template = AsyncTemplate() - template = template.from_base_image() - template = template.skip_cache().copy("C:secret.txt", "/tmp/dest.txt") - await async_build(template, alias="copy_with_windows_drive_path") - - await _expect_to_throw_and_check_trace(run, "copy") - - @pytest.mark.skip_debug() async def test_traces_on_remove(async_build): template = AsyncTemplate() diff --git a/packages/python-sdk/tests/shared/template/utils/test_is_path_outside_context.py b/packages/python-sdk/tests/shared/template/utils/test_is_path_outside_context.py new file mode 100644 index 0000000000..aa559909de --- /dev/null +++ b/packages/python-sdk/tests/shared/template/utils/test_is_path_outside_context.py @@ -0,0 +1,40 @@ +from e2b.template.utils import is_path_outside_context + + +class TestAbsolutePaths: + def test_should_return_true_for_unix_absolute_paths(self): + assert is_path_outside_context("/absolute/path") is True + + +class TestParentDirectoryTraversal: + def test_should_return_true_for_parent_directory_only(self): + assert is_path_outside_context("..") is True + + def test_should_return_true_for_paths_starting_with_dot_dot_slash(self): + assert is_path_outside_context("../file.txt") is True + + def test_should_return_true_for_paths_starting_with_dot_dot_backslash(self): + assert is_path_outside_context("..\\file.txt") is True + + def test_should_return_true_for_normalized_paths_that_escape_context(self): + assert is_path_outside_context("foo/../../bar") is True + + +class TestValidRelativePaths: + def test_should_return_false_for_simple_relative_paths(self): + assert is_path_outside_context("file.txt") is False + assert is_path_outside_context("folder/file.txt") is False + + def test_should_return_false_for_current_directory_references(self): + assert is_path_outside_context(".") is False + assert is_path_outside_context("./file.txt") is False + assert is_path_outside_context("./folder/file.txt") is False + + def test_should_return_false_for_glob_patterns(self): + assert is_path_outside_context("*.txt") is False + assert is_path_outside_context("**/*.ts") is False + assert is_path_outside_context("src/**/*") is False + + def test_should_return_false_for_hidden_files_and_directories(self): + assert is_path_outside_context(".hidden") is False + assert is_path_outside_context(".config/settings") is False diff --git a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py index 7acf5f0912..90ff1ae59f 100644 --- a/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py +++ b/packages/python-sdk/tests/sync/template_sync/test_stacktrace.py @@ -23,10 +23,6 @@ "from_gcp_registry": 0, "copy": None, "copy_items": None, - "copy_with_absolute_path": None, - "copy_with_relative_path": None, - "copy_with_embedded_dotdot": None, - "copy_with_windows_drive_path": None, "remove": 1, "rename": 1, "make_dir": 1, @@ -183,52 +179,6 @@ def test_traces_on_copyItems(build): ) -@pytest.mark.skip_debug() -def test_traces_on_copy_with_absolute_path(build): - def run(): - template = Template() - template = template.from_base_image() - template = template.skip_cache().copy("/absolute/path", "/tmp/dest.txt") - build(template, alias="copy_with_absolute_path") - - _expect_to_throw_and_check_trace(run, "copy") - - -@pytest.mark.skip_debug() -def test_traces_on_copy_with_relative_path(build): - def run(): - template = Template() - template = template.from_base_image() - template = template.skip_cache().copy("../relative/path", "/tmp/dest.txt") - build(template, alias="copy_with_relative_path") - - _expect_to_throw_and_check_trace(run, "copy") - - -@pytest.mark.skip_debug() -def test_traces_on_copy_with_embedded_dotdot(build): - def run(): - template = Template() - template = template.from_base_image() - template = template.skip_cache().copy("assets/../../secret", "/tmp/dest.txt") - build(template, alias="copy_with_embedded_dotdot") - - _expect_to_throw_and_check_trace(run, "copy") - - -@pytest.mark.skip_debug() -def test_traces_on_copy_with_windows_drive_path(build): - # Test for Windows drive-relative paths (e.g., C:foo) that could bypass - # os.path.isabs() but cause os.path.join() to discard the context directory - def run(): - template = Template() - template = template.from_base_image() - template = template.skip_cache().copy("C:secret.txt", "/tmp/dest.txt") - build(template, alias="copy_with_windows_drive_path") - - _expect_to_throw_and_check_trace(run, "copy") - - @pytest.mark.skip_debug() def test_traces_on_remove(build): template = Template() From b4961b6b87fbb9ce4452b4b93ab70dc789d77588 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:15:49 +0100 Subject: [PATCH 15/28] fix syntax err --- packages/js-sdk/src/template/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts index c271d84a6e..3ddb38ef31 100644 --- a/packages/js-sdk/src/template/index.ts +++ b/packages/js-sdk/src/template/index.ts @@ -376,9 +376,7 @@ export class TemplateBase const srcs = Array.isArray(src) ? src : [src] for (const src of srcs) { - if ( - isPathOutsideContext(src.toString(), this.fileContextPath.toString()) - ) { + if (isPathOutsideContext(src.toString())) { const error = new Error( `Source path ${src} is outside of the context directory.` ) From 21b36afe6dc197619cbdb05c05f72e5635442a47 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:30:58 +0100 Subject: [PATCH 16/28] windows rooted path --- packages/js-sdk/src/template/utils.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/js-sdk/src/template/utils.ts b/packages/js-sdk/src/template/utils.ts index 905619fe05..e63bf82cde 100644 --- a/packages/js-sdk/src/template/utils.ts +++ b/packages/js-sdk/src/template/utils.ts @@ -378,8 +378,12 @@ export function readGCPServiceAccountJSON( */ export function isPathOutsideContext(src: string): boolean { const normPath = path.normalize(src) + + // on Windows, Node's path.isAbsolute() returns false for rooted paths (e.g., /foo or \foo) which lack a drive letter + const isRootedPath = normPath.startsWith('/') || normPath.startsWith('\\') return ( path.isAbsolute(normPath) || + isRootedPath || normPath === '..' || normPath.startsWith('../') || normPath.startsWith('..\\') From 6b62a1f7902759e8d45fa396e01f8b7796b36a29 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:32:53 +0100 Subject: [PATCH 17/28] rooted path check for Windows --- packages/python-sdk/e2b/template/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/python-sdk/e2b/template/utils.py b/packages/python-sdk/e2b/template/utils.py index a1543f7c32..94d749bd93 100644 --- a/packages/python-sdk/e2b/template/utils.py +++ b/packages/python-sdk/e2b/template/utils.py @@ -328,8 +328,12 @@ def is_path_outside_context(src: str) -> bool: :return: True if the path is outside of the context directory, False otherwise """ norm_path = os.path.normpath(src) + + # on Windows, os.path.isabs() returns false for rooted paths (e.g., /foo or \foo) which lack a drive letter + is_rooted_path = norm_path.startswith("/") or norm_path.startswith("\\") return ( os.path.isabs(norm_path) + or is_rooted_path or norm_path == ".." or norm_path.startswith("../") or norm_path.startswith("..\\") From 51065a715040108dd33c7f0036be3d0dbe886c66 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:05:32 +0100 Subject: [PATCH 18/28] updated isSafeRelative implementation --- packages/js-sdk/src/template/index.ts | 12 +++-- packages/js-sdk/src/template/utils.ts | 51 ++++++++++++++----- .../utils/isPathOutsideContext.test.ts | 40 +++++++-------- packages/python-sdk/e2b/template/main.py | 12 +++-- packages/python-sdk/e2b/template/utils.py | 28 +++++----- .../utils/test_is_path_outside_context.py | 32 ++++++------ 6 files changed, 107 insertions(+), 68 deletions(-) diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts index 3ddb38ef31..623031690e 100644 --- a/packages/js-sdk/src/template/index.ts +++ b/packages/js-sdk/src/template/index.ts @@ -38,10 +38,11 @@ import { calculateFilesHash, getCallerDirectory, getCallerFrame, - isPathOutsideContext, padOctal, readDockerignore, readGCPServiceAccountJSON, + isSafeRelative, + normalizePath, } from './utils' /** @@ -376,7 +377,10 @@ export class TemplateBase const srcs = Array.isArray(src) ? src : [src] for (const src of srcs) { - if (isPathOutsideContext(src.toString())) { + const normalizedSrc = normalizePath(src.toString()) + const normalizedDest = normalizePath(dest.toString()) + + if (!isSafeRelative(normalizedSrc)) { const error = new Error( `Source path ${src} is outside of the context directory.` ) @@ -388,8 +392,8 @@ export class TemplateBase } const args = [ - src.toString(), - dest.toString(), + normalizedSrc, + normalizedDest, options?.user ?? '', options?.mode ? padOctal(options.mode) : '', ] diff --git a/packages/js-sdk/src/template/utils.ts b/packages/js-sdk/src/template/utils.ts index e63bf82cde..5b7737597b 100644 --- a/packages/js-sdk/src/template/utils.ts +++ b/packages/js-sdk/src/template/utils.ts @@ -25,12 +25,42 @@ export function readDockerignore(contextPath: string): string[] { } /** - * Normalize path separators to forward slashes for glob patterns (glob expects / even on Windows) + * Normalize paths on Windows and Unix + * * @param path - The path to normalize * @returns The normalized path */ -function normalizePath(path: string): string { - return path.replace(/\\/g, '/') +export function normalizePath(path: string): string { + // Normalize the path (resolve . and .., remove redundant slashes) + let normPath = path + .replace(/\\/g, '/') // Convert backslashes to forward slashes + .replace(/\/+/g, '/') // Replace multiple slashes with single slash + .replace(/\/\.\//g, '/') // Remove /./ segments + .replace(/\/\.$/, '') // Remove trailing /. + + // Handle ../ segments + const parts = normPath.split('/') + const stack = [] + + for (const part of parts) { + if (part === '..' && stack.length > 0 && stack[stack.length - 1] !== '..') { + stack.pop() + } else if (part !== '.' && part !== '') { + stack.push(part) + } else if (part === '' && stack.length === 0) { + // Preserve leading slash for absolute paths + stack.push(part) + } + } + + normPath = stack.join('/') + + // Strip drive letter if present (e.g., C:/) + if (normPath.length > 1 && normPath[1] === ':') { + normPath = normPath.slice(2) + } + + return normPath } /** @@ -371,19 +401,16 @@ export function readGCPServiceAccountJSON( } /** - * Check if a path is outside of the context directory. + * Returns true if src is a relative path and does not contain any up-path parts. + * Works on both Windows and Unix. * * @param src Source path - * @returns True if the path is outside of the context directory, False otherwise + * @returns boolean */ -export function isPathOutsideContext(src: string): boolean { - const normPath = path.normalize(src) - - // on Windows, Node's path.isAbsolute() returns false for rooted paths (e.g., /foo or \foo) which lack a drive letter - const isRootedPath = normPath.startsWith('/') || normPath.startsWith('\\') - return ( +export function isSafeRelative(src: string): boolean { + const normPath = normalizePath(src) + return !( path.isAbsolute(normPath) || - isRootedPath || normPath === '..' || normPath.startsWith('../') || normPath.startsWith('..\\') diff --git a/packages/js-sdk/tests/template/utils/isPathOutsideContext.test.ts b/packages/js-sdk/tests/template/utils/isPathOutsideContext.test.ts index 68992ea6a1..f39fd2a486 100644 --- a/packages/js-sdk/tests/template/utils/isPathOutsideContext.test.ts +++ b/packages/js-sdk/tests/template/utils/isPathOutsideContext.test.ts @@ -1,52 +1,52 @@ import { expect, test, describe } from 'vitest' -import { isPathOutsideContext } from '../../../src/template/utils' +import { isSafeRelative } from '../../../src/template/utils' -describe('isPathOutsideContext', () => { +describe('isSafeRelative', () => { describe('absolute paths', () => { - test('should return true for Unix absolute paths', () => { - expect(isPathOutsideContext('/absolute/path')).toBe(true) + test('should return false for Unix absolute paths', () => { + expect(isSafeRelative('/absolute/path')).toBe(false) }) }) describe('parent directory traversal', () => { - test('should return true for parent directory only', () => { - expect(isPathOutsideContext('..')).toBe(true) + test('should return false for parent directory only', () => { + expect(isSafeRelative('..')).toBe(false) }) test('should return true for paths starting with ../', () => { - expect(isPathOutsideContext('../file.txt')).toBe(true) + expect(isSafeRelative('../file.txt')).toBe(false) }) test('should return true for paths starting with ..\\', () => { - expect(isPathOutsideContext('..\\file.txt')).toBe(true) + expect(isSafeRelative('..\\file.txt')).toBe(false) }) test('should return true for normalized paths that escape context', () => { - expect(isPathOutsideContext('foo/../../bar')).toBe(true) + expect(isSafeRelative('foo/../../bar')).toBe(false) }) }) describe('valid relative paths', () => { test('should return false for simple relative paths', () => { - expect(isPathOutsideContext('file.txt')).toBe(false) - expect(isPathOutsideContext('folder/file.txt')).toBe(false) + expect(isSafeRelative('file.txt')).toBe(true) + expect(isSafeRelative('folder/file.txt')).toBe(true) }) - test('should return false for current directory references', () => { - expect(isPathOutsideContext('.')).toBe(false) - expect(isPathOutsideContext('./file.txt')).toBe(false) - expect(isPathOutsideContext('./folder/file.txt')).toBe(false) + test('should return true for current directory references', () => { + expect(isSafeRelative('.')).toBe(true) + expect(isSafeRelative('./file.txt')).toBe(true) + expect(isSafeRelative('./folder/file.txt')).toBe(true) }) test('should return false for glob patterns', () => { - expect(isPathOutsideContext('*.txt')).toBe(false) - expect(isPathOutsideContext('**/*.ts')).toBe(false) - expect(isPathOutsideContext('src/**/*')).toBe(false) + expect(isSafeRelative('*.txt')).toBe(true) + expect(isSafeRelative('**/*.ts')).toBe(true) + expect(isSafeRelative('src/**/*')).toBe(true) }) test('should return false for hidden files and directories', () => { - expect(isPathOutsideContext('.hidden')).toBe(false) - expect(isPathOutsideContext('.config/settings')).toBe(false) + expect(isSafeRelative('.hidden')).toBe(true) + expect(isSafeRelative('.config/settings')).toBe(true) }) }) }) diff --git a/packages/python-sdk/e2b/template/main.py b/packages/python-sdk/e2b/template/main.py index a51cfc51df..76ce739c62 100644 --- a/packages/python-sdk/e2b/template/main.py +++ b/packages/python-sdk/e2b/template/main.py @@ -21,7 +21,8 @@ read_dockerignore, read_gcp_service_account_json, get_caller_frame, - is_path_outside_context, + is_safe_relative, + normalize_path, ) from types import TracebackType @@ -66,7 +67,10 @@ def copy( srcs = [src] if isinstance(src, (str, Path)) else src for src_item in srcs: - if is_path_outside_context(str(src_item)): + normalized_src_item = normalize_path(str(src_item)) + normalized_dest = normalize_path(str(dest)) + + if not is_safe_relative(str(src_item)): caller_frame = get_caller_frame(STACK_TRACE_DEPTH - 1) stack_trace = None if caller_frame is not None: @@ -82,8 +86,8 @@ def copy( ).with_traceback(stack_trace) args = [ - str(src_item), - str(dest), + normalized_src_item, + normalized_dest, user or "", pad_octal(mode) if mode else "", ] diff --git a/packages/python-sdk/e2b/template/utils.py b/packages/python-sdk/e2b/template/utils.py index 94d749bd93..8801c0c2a9 100644 --- a/packages/python-sdk/e2b/template/utils.py +++ b/packages/python-sdk/e2b/template/utils.py @@ -1,6 +1,7 @@ import hashlib import os import io +from pathlib import Path import tarfile import json import stat @@ -37,12 +38,17 @@ def read_dockerignore(context_path: str) -> List[str]: def normalize_path(path: str) -> str: """ - Normalize path separators to forward slashes for glob patterns (glob expects / even on Windows). + Normalize paths on Windows and Unix :param path: The path to normalize :return: The normalized path """ - return path.replace(os.sep, "/") + norm_path = Path(os.path.normpath(path)).as_posix() + # strip drive letter if present + if len(norm_path) > 1 and norm_path[1] == ":": + norm_path = norm_path[2:] + + return norm_path def get_all_files_in_path( @@ -320,20 +326,18 @@ def read_gcp_service_account_json( return json.dumps(path_or_content) -def is_path_outside_context(src: str) -> bool: +def is_safe_relative(src: str) -> bool: """ - Check if a path is outside of the context directory. + Returns True if src is a relative path and does not contain any up-path parts. + Works on both Windows and Unix. - :param src: Path to check - :return: True if the path is outside of the context directory, False otherwise - """ - norm_path = os.path.normpath(src) + :param src: The path to check - # on Windows, os.path.isabs() returns false for rooted paths (e.g., /foo or \foo) which lack a drive letter - is_rooted_path = norm_path.startswith("/") or norm_path.startswith("\\") - return ( + :return: True if the path is a safe relative path, False otherwise + """ + norm_path = normalize_path(src) + return not ( os.path.isabs(norm_path) - or is_rooted_path or norm_path == ".." or norm_path.startswith("../") or norm_path.startswith("..\\") diff --git a/packages/python-sdk/tests/shared/template/utils/test_is_path_outside_context.py b/packages/python-sdk/tests/shared/template/utils/test_is_path_outside_context.py index aa559909de..f764290dc4 100644 --- a/packages/python-sdk/tests/shared/template/utils/test_is_path_outside_context.py +++ b/packages/python-sdk/tests/shared/template/utils/test_is_path_outside_context.py @@ -1,40 +1,40 @@ -from e2b.template.utils import is_path_outside_context +from e2b.template.utils import is_safe_relative class TestAbsolutePaths: def test_should_return_true_for_unix_absolute_paths(self): - assert is_path_outside_context("/absolute/path") is True + assert is_safe_relative("/absolute/path") is False class TestParentDirectoryTraversal: def test_should_return_true_for_parent_directory_only(self): - assert is_path_outside_context("..") is True + assert is_safe_relative("..") is False def test_should_return_true_for_paths_starting_with_dot_dot_slash(self): - assert is_path_outside_context("../file.txt") is True + assert is_safe_relative("../file.txt") is False def test_should_return_true_for_paths_starting_with_dot_dot_backslash(self): - assert is_path_outside_context("..\\file.txt") is True + assert is_safe_relative("..\\file.txt") is False def test_should_return_true_for_normalized_paths_that_escape_context(self): - assert is_path_outside_context("foo/../../bar") is True + assert is_safe_relative("foo/../../bar") is False class TestValidRelativePaths: def test_should_return_false_for_simple_relative_paths(self): - assert is_path_outside_context("file.txt") is False - assert is_path_outside_context("folder/file.txt") is False + assert is_safe_relative("file.txt") is True + assert is_safe_relative("folder/file.txt") is True def test_should_return_false_for_current_directory_references(self): - assert is_path_outside_context(".") is False - assert is_path_outside_context("./file.txt") is False - assert is_path_outside_context("./folder/file.txt") is False + assert is_safe_relative(".") is True + assert is_safe_relative("./file.txt") is True + assert is_safe_relative("./folder/file.txt") is True def test_should_return_false_for_glob_patterns(self): - assert is_path_outside_context("*.txt") is False - assert is_path_outside_context("**/*.ts") is False - assert is_path_outside_context("src/**/*") is False + assert is_safe_relative("*.txt") is True + assert is_safe_relative("**/*.ts") is True + assert is_safe_relative("src/**/*") is True def test_should_return_false_for_hidden_files_and_directories(self): - assert is_path_outside_context(".hidden") is False - assert is_path_outside_context(".config/settings") is False + assert is_safe_relative(".hidden") is True + assert is_safe_relative(".config/settings") is True From b141fd0383ccbe883a760e4813c2274ecd8041c1 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:10:14 +0100 Subject: [PATCH 19/28] renamed --- .../{isPathOutsideContext.test.ts => isSafeRelative.test.ts} | 0 .../{test_is_path_outside_context.py => is_safe_relative.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/js-sdk/tests/template/utils/{isPathOutsideContext.test.ts => isSafeRelative.test.ts} (100%) rename packages/python-sdk/tests/shared/template/utils/{test_is_path_outside_context.py => is_safe_relative.py} (100%) diff --git a/packages/js-sdk/tests/template/utils/isPathOutsideContext.test.ts b/packages/js-sdk/tests/template/utils/isSafeRelative.test.ts similarity index 100% rename from packages/js-sdk/tests/template/utils/isPathOutsideContext.test.ts rename to packages/js-sdk/tests/template/utils/isSafeRelative.test.ts diff --git a/packages/python-sdk/tests/shared/template/utils/test_is_path_outside_context.py b/packages/python-sdk/tests/shared/template/utils/is_safe_relative.py similarity index 100% rename from packages/python-sdk/tests/shared/template/utils/test_is_path_outside_context.py rename to packages/python-sdk/tests/shared/template/utils/is_safe_relative.py From 034dcaddbdc9752b01c164a372f4e9ff1e967fbc Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:11:42 +0100 Subject: [PATCH 20/28] redo normalizePath function --- packages/js-sdk/src/template/utils.ts | 70 ++++++++++++++++++--------- 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/packages/js-sdk/src/template/utils.ts b/packages/js-sdk/src/template/utils.ts index 5b7737597b..04e47ffa5f 100644 --- a/packages/js-sdk/src/template/utils.ts +++ b/packages/js-sdk/src/template/utils.ts @@ -31,36 +31,62 @@ export function readDockerignore(contextPath: string): string[] { * @returns The normalized path */ export function normalizePath(path: string): string { - // Normalize the path (resolve . and .., remove redundant slashes) - let normPath = path - .replace(/\\/g, '/') // Convert backslashes to forward slashes - .replace(/\/+/g, '/') // Replace multiple slashes with single slash - .replace(/\/\.\//g, '/') // Remove /./ segments - .replace(/\/\.$/, '') // Remove trailing /. + if (!path || path === '.') { + return '.' + } + + // Detect Windows path (drive letter or UNC path) + const isWindows = /^[a-zA-Z]:/.test(path) || path.startsWith('\\\\') + const separator = isWindows ? '\\' : '/' + + // Extract drive letter if present (e.g., 'C:') + let driveLetter = '' + let workingPath = path + + if (/^[a-zA-Z]:/.test(path)) { + driveLetter = path.substring(0, 2) + workingPath = path.substring(2) + } - // Handle ../ segments - const parts = normPath.split('/') - const stack = [] + // Check if path is absolute + const isAbsolute = workingPath.startsWith('/') || workingPath.startsWith('\\') + + // Normalize separators and split + const normalizedPath = workingPath.replace(/[\\/]+/g, '/') + const parts = normalizedPath.split('/').filter((part) => part && part !== '.') + + const normalized: string[] = [] for (const part of parts) { - if (part === '..' && stack.length > 0 && stack[stack.length - 1] !== '..') { - stack.pop() - } else if (part !== '.' && part !== '') { - stack.push(part) - } else if (part === '' && stack.length === 0) { - // Preserve leading slash for absolute paths - stack.push(part) + if (part === '..') { + // Go up one directory if possible (but not past root for absolute paths) + if (normalized.length > 0 && normalized[normalized.length - 1] !== '..') { + normalized.pop() + } else if (!isAbsolute) { + // For relative paths, keep the '..' if we can't go up further + normalized.push('..') + } + } else { + normalized.push(part) } } - normPath = stack.join('/') - - // Strip drive letter if present (e.g., C:/) - if (normPath.length > 1 && normPath[1] === ':') { - normPath = normPath.slice(2) + // Build the final path + let result = normalized.join('/') + + // Add drive letter back if present + if (driveLetter) { + result = + driveLetter + (isAbsolute ? '\\' : '') + result.replace(/\//g, '\\') + } else if (isAbsolute) { + result = separator + result.replace(/\//g, separator) + } else { + // For relative paths, use appropriate separator + result = result.replace(/\//g, separator) } - return normPath + // Return '.' for empty relative paths + return result || '.' } /** From 9ec44816f91134abd4f4cb8f68f6ab36a9acc691 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:14:46 +0100 Subject: [PATCH 21/28] use posix style paths --- packages/js-sdk/src/template/utils.ts | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/packages/js-sdk/src/template/utils.ts b/packages/js-sdk/src/template/utils.ts index 04e47ffa5f..f684cfd20b 100644 --- a/packages/js-sdk/src/template/utils.ts +++ b/packages/js-sdk/src/template/utils.ts @@ -35,23 +35,16 @@ export function normalizePath(path: string): string { return '.' } - // Detect Windows path (drive letter or UNC path) - const isWindows = /^[a-zA-Z]:/.test(path) || path.startsWith('\\\\') - const separator = isWindows ? '\\' : '/' - - // Extract drive letter if present (e.g., 'C:') - let driveLetter = '' + // Remove drive letter if present (e.g., 'C:') let workingPath = path - if (/^[a-zA-Z]:/.test(path)) { - driveLetter = path.substring(0, 2) workingPath = path.substring(2) } // Check if path is absolute const isAbsolute = workingPath.startsWith('/') || workingPath.startsWith('\\') - // Normalize separators and split + // Normalize all separators to forward slashes and split const normalizedPath = workingPath.replace(/[\\/]+/g, '/') const parts = normalizedPath.split('/').filter((part) => part && part !== '.') @@ -71,19 +64,8 @@ export function normalizePath(path: string): string { } } - // Build the final path - let result = normalized.join('/') - - // Add drive letter back if present - if (driveLetter) { - result = - driveLetter + (isAbsolute ? '\\' : '') + result.replace(/\//g, '\\') - } else if (isAbsolute) { - result = separator + result.replace(/\//g, separator) - } else { - // For relative paths, use appropriate separator - result = result.replace(/\//g, separator) - } + // Build the final path in POSIX style + const result = (isAbsolute ? '/' : '') + normalized.join('/') // Return '.' for empty relative paths return result || '.' From 5b1199983a0deec2610f56f14137d69fcd8f1f9b Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:21:35 +0100 Subject: [PATCH 22/28] update tests --- .../template/methods/fromDockerfile.test.ts | 4 +- .../template/utils/normalizePath.test.ts | 78 +++++++++++++++++++ .../methods/test_from_dockerfile.py | 4 +- .../shared/template/utils/normalize_path.py | 63 +++++++++++++++ 4 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 packages/js-sdk/tests/template/utils/normalizePath.test.ts create mode 100644 packages/python-sdk/tests/shared/template/utils/normalize_path.py diff --git a/packages/js-sdk/tests/template/methods/fromDockerfile.test.ts b/packages/js-sdk/tests/template/methods/fromDockerfile.test.ts index 70e34f742c..76235bf5cc 100644 --- a/packages/js-sdk/tests/template/methods/fromDockerfile.test.ts +++ b/packages/js-sdk/tests/template/methods/fromDockerfile.test.ts @@ -143,7 +143,7 @@ COPY --chown=anotheruser config.json /config/` const copyInstruction1 = template.instructions[2] assert.equal(copyInstruction1.type, InstructionType.COPY) assert.equal(copyInstruction1.args[0], 'app.js') - assert.equal(copyInstruction1.args[1], '/app/') + assert.equal(copyInstruction1.args[1], '/app') assert.equal(copyInstruction1.args[2], 'myuser:mygroup') // user from --chown // Second COPY instruction @@ -151,6 +151,6 @@ COPY --chown=anotheruser config.json /config/` const copyInstruction2 = template.instructions[3] assert.equal(copyInstruction2.type, InstructionType.COPY) assert.equal(copyInstruction2.args[0], 'config.json') - assert.equal(copyInstruction2.args[1], '/config/') + assert.equal(copyInstruction2.args[1], '/config') assert.equal(copyInstruction2.args[2], 'anotheruser') // user from --chown (without group) }) diff --git a/packages/js-sdk/tests/template/utils/normalizePath.test.ts b/packages/js-sdk/tests/template/utils/normalizePath.test.ts new file mode 100644 index 0000000000..a25978c57c --- /dev/null +++ b/packages/js-sdk/tests/template/utils/normalizePath.test.ts @@ -0,0 +1,78 @@ +import { expect, test, describe } from 'vitest' +import { normalizePath } from '../../../src/template/utils' + +describe('normalizePath', () => { + describe('basic path normalization', () => { + test('should resolve parent directory references', () => { + expect(normalizePath('/foo/bar/../baz')).toBe('/foo/baz') + }) + + test('should remove current directory references', () => { + expect(normalizePath('foo/./bar')).toBe('foo/bar') + }) + + test('should collapse multiple slashes', () => { + expect(normalizePath('foo//bar///baz')).toBe('foo/bar/baz') + }) + + test('should handle multiple parent directory traversals in relative paths', () => { + expect(normalizePath('../foo/../../bar')).toBe('../../bar') + }) + + test('should not traverse past root for absolute paths', () => { + expect(normalizePath('/foo/../../bar')).toBe('/bar') + }) + + test('should return dot for empty path', () => { + expect(normalizePath('')).toBe('.') + }) + + test('should remove leading current directory reference', () => { + expect(normalizePath('./foo/bar')).toBe('foo/bar') + }) + }) + + describe('Windows paths converted to POSIX style', () => { + test('should normalize Windows path with drive letter and backslashes', () => { + expect(normalizePath('C:\\foo\\bar\\..\\baz')).toBe('/foo/baz') + }) + + test('should normalize Windows path with drive letter and forward slashes', () => { + expect(normalizePath('C:/foo/bar/../baz')).toBe('/foo/baz') + }) + + test('should normalize backslash with current directory reference', () => { + expect(normalizePath('foo\\.\\bar')).toBe('foo/bar') + }) + + test('should handle backslash parent directory traversal', () => { + expect(normalizePath('..\\..\\foo')).toBe('../../foo') + }) + + test('should normalize drive letter root to POSIX root', () => { + expect(normalizePath('C:\\')).toBe('/') + }) + }) + + describe('edge cases', () => { + test('should return dot for current directory', () => { + expect(normalizePath('.')).toBe('.') + }) + + test('should handle parent directory only', () => { + expect(normalizePath('..')).toBe('..') + }) + + test('should handle absolute root path', () => { + expect(normalizePath('/')).toBe('/') + }) + + test('should handle complex nested path', () => { + expect(normalizePath('a/b/c/../../d/./e/../f')).toBe('a/d/f') + }) + + test('should preserve trailing segments after parent traversal', () => { + expect(normalizePath('a/../b/../c')).toBe('c') + }) + }) +}) diff --git a/packages/python-sdk/tests/async/template_async/methods/test_from_dockerfile.py b/packages/python-sdk/tests/async/template_async/methods/test_from_dockerfile.py index 426ed010c2..a1dcc00988 100644 --- a/packages/python-sdk/tests/async/template_async/methods/test_from_dockerfile.py +++ b/packages/python-sdk/tests/async/template_async/methods/test_from_dockerfile.py @@ -80,14 +80,14 @@ async def test_from_dockerfile_with_copy_chown(): copy_instruction1 = instructions[2] assert copy_instruction1["type"] == InstructionType.COPY assert copy_instruction1["args"][0] == "app.js" - assert copy_instruction1["args"][1] == "/app/" + assert copy_instruction1["args"][1] == "/app" assert copy_instruction1["args"][2] == "myuser:mygroup" # user from --chown # Second COPY instruction copy_instruction2 = instructions[3] assert copy_instruction2["type"] == InstructionType.COPY assert copy_instruction2["args"][0] == "config.json" - assert copy_instruction2["args"][1] == "/config/" + assert copy_instruction2["args"][1] == "/config" assert ( copy_instruction2["args"][2] == "anotheruser" ) # user from --chown (without group) diff --git a/packages/python-sdk/tests/shared/template/utils/normalize_path.py b/packages/python-sdk/tests/shared/template/utils/normalize_path.py new file mode 100644 index 0000000000..66a7b885f7 --- /dev/null +++ b/packages/python-sdk/tests/shared/template/utils/normalize_path.py @@ -0,0 +1,63 @@ +from e2b.template.utils import normalize_path + + +class TestBasicPathNormalization: + def test_should_resolve_parent_directory_references(self): + assert normalize_path("/foo/bar/../baz") == "/foo/baz" + + def test_should_remove_current_directory_references(self): + assert normalize_path("foo/./bar") == "foo/bar" + + def test_should_collapse_multiple_slashes(self): + assert normalize_path("foo//bar///baz") == "foo/bar/baz" + + def test_should_handle_multiple_parent_directory_traversals_in_relative_paths(self): + assert normalize_path("../foo/../../bar") == "../../bar" + + def test_should_not_traverse_past_root_for_absolute_paths(self): + assert normalize_path("/foo/../../bar") == "/bar" + + def test_should_return_dot_for_empty_path(self): + assert normalize_path("") == "." + + def test_should_remove_leading_current_directory_reference(self): + assert normalize_path("./foo/bar") == "foo/bar" + + +class TestWindowsPathsConvertedToPosixStyle: + """ + Note: On Unix systems, backslash is a valid filename character, not a path separator. + The Python implementation uses os.path.normpath which is OS-dependent. + These tests use forward slashes which work correctly cross-platform. + """ + + def test_should_normalize_windows_path_with_drive_letter_and_forward_slashes(self): + assert normalize_path("C:/foo/bar/../baz") == "/foo/baz" + + def test_should_strip_drive_letter(self): + assert normalize_path("C:/foo/bar") == "/foo/bar" + + def test_should_strip_lowercase_drive_letter(self): + assert normalize_path("c:/foo/bar") == "/foo/bar" + + def test_should_strip_drive_letter_with_simple_path(self): + # Note: Python's os.path.normpath normalizes "D:/" to "D:" on Unix, + # and after stripping drive letter and calling as_posix(), result is empty string + assert normalize_path("D:/") == "" + + +class TestEdgeCases: + def test_should_return_dot_for_current_directory(self): + assert normalize_path(".") == "." + + def test_should_handle_parent_directory_only(self): + assert normalize_path("..") == ".." + + def test_should_handle_absolute_root_path(self): + assert normalize_path("/") == "/" + + def test_should_handle_complex_nested_path(self): + assert normalize_path("a/b/c/../../d/./e/../f") == "a/d/f" + + def test_should_preserve_trailing_segments_after_parent_traversal(self): + assert normalize_path("a/../b/../c") == "c" From eada42b4272272643982f21e26fc397d57db72db Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:25:36 +0100 Subject: [PATCH 23/28] updated generator test signatures --- .../copy-variations/expected/python-async/template.py | 4 ++-- .../copy-variations/expected/python-sync/template.py | 4 ++-- .../copy-variations/expected/typescript/template.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-async/template.py b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-async/template.py index 76193e6122..1e4e7ff3e5 100644 --- a/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-async/template.py +++ b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-async/template.py @@ -5,8 +5,8 @@ .from_image("alpine:latest") .set_user("root") .set_workdir("/") - .copy("package.json", "/app/") - .copy("src/index.js", "./src/") + .copy("package.json", "/app") + .copy("src/index.js", "src") .copy("config.json", "/etc/app/config.json") .set_user("user") .set_workdir("/home/user") diff --git a/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-sync/template.py b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-sync/template.py index eda0d72a7d..dedaf69168 100644 --- a/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-sync/template.py +++ b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/python-sync/template.py @@ -5,8 +5,8 @@ .from_image("alpine:latest") .set_user("root") .set_workdir("/") - .copy("package.json", "/app/") - .copy("src/index.js", "./src/") + .copy("package.json", "/app") + .copy("src/index.js", "src") .copy("config.json", "/etc/app/config.json") .set_user("user") .set_workdir("/home/user") diff --git a/packages/cli/tests/commands/template/fixtures/copy-variations/expected/typescript/template.ts b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/typescript/template.ts index 835891e758..61bc2f9657 100644 --- a/packages/cli/tests/commands/template/fixtures/copy-variations/expected/typescript/template.ts +++ b/packages/cli/tests/commands/template/fixtures/copy-variations/expected/typescript/template.ts @@ -4,8 +4,8 @@ export const template = Template() .fromImage('alpine:latest') .setUser('root') .setWorkdir('/') - .copy('package.json', '/app/') - .copy('src/index.js', './src/') + .copy('package.json', '/app') + .copy('src/index.js', 'src') .copy('config.json', '/etc/app/config.json') .setUser('user') - .setWorkdir('/home/user') \ No newline at end of file + .setWorkdir('/home/user') From 356d3ec4f4aa61a31ce08dd71215994df8028909 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:35:48 +0100 Subject: [PATCH 24/28] updated tests --- .../js-sdk/tests/template/methods/fromDockerfile.test.ts | 4 ++-- .../async/template_async/methods/test_from_dockerfile.py | 4 ++-- .../sync/template_sync/methods/test_from_dockerfile.py | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/js-sdk/tests/template/methods/fromDockerfile.test.ts b/packages/js-sdk/tests/template/methods/fromDockerfile.test.ts index 76235bf5cc..250deb87f3 100644 --- a/packages/js-sdk/tests/template/methods/fromDockerfile.test.ts +++ b/packages/js-sdk/tests/template/methods/fromDockerfile.test.ts @@ -133,8 +133,8 @@ buildTemplateTest('fromDockerfile with custom user and workdir', () => { buildTemplateTest('fromDockerfile with COPY --chown', () => { const dockerfile = `FROM node:24 -COPY --chown=myuser:mygroup app.js /app/ -COPY --chown=anotheruser config.json /config/` +COPY --chown=myuser:mygroup app.js /app +COPY --chown=anotheruser config.json /config` const template = Template().fromDockerfile(dockerfile) diff --git a/packages/python-sdk/tests/async/template_async/methods/test_from_dockerfile.py b/packages/python-sdk/tests/async/template_async/methods/test_from_dockerfile.py index a1dcc00988..0e456d0041 100644 --- a/packages/python-sdk/tests/async/template_async/methods/test_from_dockerfile.py +++ b/packages/python-sdk/tests/async/template_async/methods/test_from_dockerfile.py @@ -69,8 +69,8 @@ async def test_from_dockerfile_with_custom_user_and_workdir(): @pytest.mark.skip_debug() async def test_from_dockerfile_with_copy_chown(): dockerfile = """FROM node:24 -COPY --chown=myuser:mygroup app.js /app/ -COPY --chown=anotheruser config.json /config/""" +COPY --chown=myuser:mygroup app.js /app +COPY --chown=anotheruser config.json /config""" template = AsyncTemplate().from_dockerfile(dockerfile) diff --git a/packages/python-sdk/tests/sync/template_sync/methods/test_from_dockerfile.py b/packages/python-sdk/tests/sync/template_sync/methods/test_from_dockerfile.py index b8ff1966ee..a48469bf5c 100644 --- a/packages/python-sdk/tests/sync/template_sync/methods/test_from_dockerfile.py +++ b/packages/python-sdk/tests/sync/template_sync/methods/test_from_dockerfile.py @@ -69,8 +69,8 @@ def test_from_dockerfile_with_custom_user_and_workdir(): @pytest.mark.skip_debug() def test_from_dockerfile_with_copy_chown(): dockerfile = """FROM node:24 -COPY --chown=myuser:mygroup app.js /app/ -COPY --chown=anotheruser config.json /config/""" +COPY --chown=myuser:mygroup app.js /app +COPY --chown=anotheruser config.json /config""" template = Template().from_dockerfile(dockerfile) @@ -80,14 +80,14 @@ def test_from_dockerfile_with_copy_chown(): copy_instruction1 = instructions[2] assert copy_instruction1["type"] == InstructionType.COPY assert copy_instruction1["args"][0] == "app.js" - assert copy_instruction1["args"][1] == "/app/" + assert copy_instruction1["args"][1] == "/app" assert copy_instruction1["args"][2] == "myuser:mygroup" # user from --chown # Second COPY instruction copy_instruction2 = instructions[3] assert copy_instruction2["type"] == InstructionType.COPY assert copy_instruction2["args"][0] == "config.json" - assert copy_instruction2["args"][1] == "/config/" + assert copy_instruction2["args"][1] == "/config" assert ( copy_instruction2["args"][2] == "anotheruser" ) # user from --chown (without group) From f95ade5b8dbcd77012371da617ae6abc882ac029 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:50:08 +0100 Subject: [PATCH 25/28] renamed --- .../{get_all_files_in_path.py => test_get_all_files_in_path.py} | 0 .../utils/{is_safe_relative.py => test_is_safe_relative.py} | 0 .../template/utils/{normalize_path.py => test_normalize_path.py} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename packages/python-sdk/tests/shared/template/utils/{get_all_files_in_path.py => test_get_all_files_in_path.py} (100%) rename packages/python-sdk/tests/shared/template/utils/{is_safe_relative.py => test_is_safe_relative.py} (100%) rename packages/python-sdk/tests/shared/template/utils/{normalize_path.py => test_normalize_path.py} (100%) diff --git a/packages/python-sdk/tests/shared/template/utils/get_all_files_in_path.py b/packages/python-sdk/tests/shared/template/utils/test_get_all_files_in_path.py similarity index 100% rename from packages/python-sdk/tests/shared/template/utils/get_all_files_in_path.py rename to packages/python-sdk/tests/shared/template/utils/test_get_all_files_in_path.py diff --git a/packages/python-sdk/tests/shared/template/utils/is_safe_relative.py b/packages/python-sdk/tests/shared/template/utils/test_is_safe_relative.py similarity index 100% rename from packages/python-sdk/tests/shared/template/utils/is_safe_relative.py rename to packages/python-sdk/tests/shared/template/utils/test_is_safe_relative.py diff --git a/packages/python-sdk/tests/shared/template/utils/normalize_path.py b/packages/python-sdk/tests/shared/template/utils/test_normalize_path.py similarity index 100% rename from packages/python-sdk/tests/shared/template/utils/normalize_path.py rename to packages/python-sdk/tests/shared/template/utils/test_normalize_path.py From 52417cddb70f0dd0a94307676ca78cfcd6bb1436 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Tue, 13 Jan 2026 23:13:26 +0100 Subject: [PATCH 26/28] fixes windows gotcha --- packages/python-sdk/e2b/template/utils.py | 3 +++ .../tests/shared/template/utils/test_normalize_path.py | 4 +--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/python-sdk/e2b/template/utils.py b/packages/python-sdk/e2b/template/utils.py index 8801c0c2a9..dd3f77802a 100644 --- a/packages/python-sdk/e2b/template/utils.py +++ b/packages/python-sdk/e2b/template/utils.py @@ -47,6 +47,9 @@ def normalize_path(path: str) -> str: # strip drive letter if present if len(norm_path) > 1 and norm_path[1] == ":": norm_path = norm_path[2:] + # A bare drive letter (e.g., "D:/") should become "/" (the root) + if norm_path == "": + norm_path = "/" return norm_path diff --git a/packages/python-sdk/tests/shared/template/utils/test_normalize_path.py b/packages/python-sdk/tests/shared/template/utils/test_normalize_path.py index 66a7b885f7..ae1fd21a41 100644 --- a/packages/python-sdk/tests/shared/template/utils/test_normalize_path.py +++ b/packages/python-sdk/tests/shared/template/utils/test_normalize_path.py @@ -41,9 +41,7 @@ def test_should_strip_lowercase_drive_letter(self): assert normalize_path("c:/foo/bar") == "/foo/bar" def test_should_strip_drive_letter_with_simple_path(self): - # Note: Python's os.path.normpath normalizes "D:/" to "D:" on Unix, - # and after stripping drive letter and calling as_posix(), result is empty string - assert normalize_path("D:/") == "" + assert normalize_path("D:/") == "/" class TestEdgeCases: From e41ffaef774755104b67a5eb6dfa68035d976a60 Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Wed, 14 Jan 2026 12:35:56 +0100 Subject: [PATCH 27/28] corrected the py path normalize implementation to match JS version --- packages/python-sdk/e2b/template/utils.py | 44 ++++++++++++++++------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/packages/python-sdk/e2b/template/utils.py b/packages/python-sdk/e2b/template/utils.py index dd3f77802a..13c8ab2c3d 100644 --- a/packages/python-sdk/e2b/template/utils.py +++ b/packages/python-sdk/e2b/template/utils.py @@ -38,21 +38,41 @@ def read_dockerignore(context_path: str) -> List[str]: def normalize_path(path: str) -> str: """ - Normalize paths on Windows and Unix - - :param path: The path to normalize - :return: The normalized path + Normalize paths in a platform-independent, POSIX-style manner. + Mirrors the JS normalizePath behavior. """ - norm_path = Path(os.path.normpath(path)).as_posix() - # strip drive letter if present - if len(norm_path) > 1 and norm_path[1] == ":": - norm_path = norm_path[2:] - # A bare drive letter (e.g., "D:/") should become "/" (the root) - if norm_path == "": - norm_path = "/" + if not path or path == ".": + return "." + + # Remove drive letter if present (e.g. "C:") + working_path = path + if re.match(r"^[a-zA-Z]:", path): + working_path = path[2:] + + # Determine if absolute + is_absolute = working_path.startswith("/") or working_path.startswith("\\") + + # Normalize separators to '/' + normalized_path = re.sub(r"[\\/]+", "/", working_path) + + # Split and process components + parts = [p for p in normalized_path.split("/") if p and p != "."] + + normalized = [] + + for part in parts: + if part == "..": + if normalized and normalized[-1] != "..": + normalized.pop() + elif not is_absolute: + normalized.append("..") + else: + normalized.append(part) - return norm_path + # Reconstruct path + result = ("/" if is_absolute else "") + "/".join(normalized) + return result or "." def get_all_files_in_path( src: str, From 56b668fe6df474760cd22925826296b2699e99ad Mon Sep 17 00:00:00 2001 From: Mish <10400064+mishushakov@users.noreply.github.com> Date: Wed, 14 Jan 2026 12:38:48 +0100 Subject: [PATCH 28/28] lint --- packages/python-sdk/e2b/template/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/python-sdk/e2b/template/utils.py b/packages/python-sdk/e2b/template/utils.py index 13c8ab2c3d..d3036b8e82 100644 --- a/packages/python-sdk/e2b/template/utils.py +++ b/packages/python-sdk/e2b/template/utils.py @@ -1,7 +1,6 @@ import hashlib import os import io -from pathlib import Path import tarfile import json import stat @@ -74,6 +73,7 @@ def normalize_path(path: str) -> str: return result or "." + def get_all_files_in_path( src: str, context_path: str,