Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
da29603
throw when copying from dirs other than context dir
mishushakov Jan 8, 2026
49e1d63
added path normalization
mishushakov Jan 8, 2026
6c56d95
upd
mishushakov Jan 8, 2026
5187988
moved path handling to files hash (buld-time)
mishushakov Jan 8, 2026
484cbbc
remove unused vars
mishushakov Jan 8, 2026
e02de9c
remove unused imports
mishushakov Jan 8, 2026
850df11
format
mishushakov Jan 8, 2026
a6ea577
added isabs check
mishushakov Jan 8, 2026
d1e8f00
Merge branch 'main' into fix-throw-on-copy-abs-rel-paths
mishushakov Jan 12, 2026
bff918b
moved path checking to .copy
mishushakov Jan 12, 2026
040aef9
normalize path only in is_path_outside_context
mishushakov Jan 12, 2026
3c6bf6d
lint
mishushakov Jan 12, 2026
2f7de17
lint again
mishushakov Jan 12, 2026
1144910
added comment
mishushakov Jan 13, 2026
b8ac1a6
redo tests
mishushakov Jan 13, 2026
b4961b6
fix syntax err
mishushakov Jan 13, 2026
21b36af
windows rooted path
mishushakov Jan 13, 2026
6b62a1f
rooted path check for Windows
mishushakov Jan 13, 2026
51065a7
updated isSafeRelative implementation
mishushakov Jan 13, 2026
b141fd0
renamed
mishushakov Jan 13, 2026
034dcad
redo normalizePath function
mishushakov Jan 13, 2026
9ec4481
use posix style paths
mishushakov Jan 13, 2026
5b11999
update tests
mishushakov Jan 13, 2026
eada42b
updated generator test signatures
mishushakov Jan 13, 2026
356d3ec
updated tests
mishushakov Jan 13, 2026
f95ade5
renamed
mishushakov Jan 13, 2026
52417cd
fixes windows gotcha
mishushakov Jan 13, 2026
e41ffae
corrected the py path normalize implementation to match JS version
mishushakov Jan 14, 2026
56b668f
lint
mishushakov Jan 14, 2026
bd60304
Merge branch 'main' into fix-throw-on-copy-abs-rel-paths
mishushakov Jan 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/silver-groups-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@e2b/python-sdk': patch
'e2b': patch
---

throw when copying paths outside of the context dir
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
.setWorkdir('/home/user')
20 changes: 18 additions & 2 deletions packages/js-sdk/src/template/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import {
padOctal,
readDockerignore,
readGCPServiceAccountJSON,
isSafeRelative,
normalizePath,
} from './utils'

/**
Expand Down Expand Up @@ -522,9 +524,23 @@ export class TemplateBase
const srcs = Array.isArray(src) ? src : [src]

for (const src of srcs) {
const normalizedSrc = normalizePath(src.toString())
const normalizedDest = normalizePath(dest.toString())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can move this outside of the for loop


if (!isSafeRelative(normalizedSrc)) {
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(),
dest.toString(),
normalizedSrc,
normalizedDest,
options?.user ?? '',
options?.mode ? padOctal(options.mode) : '',
]
Expand Down
61 changes: 58 additions & 3 deletions packages/js-sdk/src/template/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,50 @@ 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 {
if (!path || path === '.') {
return '.'
}

// Remove drive letter if present (e.g., 'C:')
let workingPath = path
if (/^[a-zA-Z]:/.test(path)) {
workingPath = path.substring(2)
}

// Check if path is absolute
const isAbsolute = workingPath.startsWith('/') || workingPath.startsWith('\\')

// Normalize all separators to forward slashes and split
const normalizedPath = workingPath.replace(/[\\/]+/g, '/')
Comment on lines +82 to +86
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Check if path is absolute
const isAbsolute = workingPath.startsWith('/') || workingPath.startsWith('\\')
// Normalize all separators to forward slashes and split
const normalizedPath = workingPath.replace(/[\\/]+/g, '/')
// Normalize all separators to forward slashes and split
const normalizedPath = workingPath.replace(/[\\/]+/g, '/')
// Check if path is absolute
const isAbsolute = workingPath.startsWith('/')

const parts = normalizedPath.split('/').filter((part) => part && part !== '.')

const normalized: string[] = []

for (const part of parts) {
if (part === '..') {
// Go up one directory if possible (but not past root for absolute paths)
if (normalized.length > 0 && normalized[normalized.length - 1] !== '..') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if last part was '.'?

normalized.pop()
} else if (!isAbsolute) {
// For relative paths, keep the '..' if we can't go up further
normalized.push('..')
}
} else {
normalized.push(part)
}
}

// Build the final path in POSIX style
const result = (isAbsolute ? '/' : '') + normalized.join('/')

// Return '.' for empty relative paths
return result || '.'
}

/**
Expand Down Expand Up @@ -408,3 +446,20 @@ export function readGCPServiceAccountJSON(
}
return JSON.stringify(pathOrContent)
}

/**
* 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 boolean
*/
export function isSafeRelative(src: string): boolean {
const normPath = normalizePath(src)
return !(
path.isAbsolute(normPath) ||
normPath === '..' ||
normPath.startsWith('../') ||
normPath.startsWith('..\\')
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -143,14 +143,14 @@ 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
// @ts-expect-error - instructions is not a property of TemplateBuilder
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)
})
2 changes: 1 addition & 1 deletion packages/js-sdk/tests/template/stacktrace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { apiUrl, 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<string, number | undefined> = {
Expand Down
52 changes: 52 additions & 0 deletions packages/js-sdk/tests/template/utils/isSafeRelative.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { expect, test, describe } from 'vitest'
import { isSafeRelative } from '../../../src/template/utils'

describe('isSafeRelative', () => {
describe('absolute paths', () => {
test('should return false for Unix absolute paths', () => {
expect(isSafeRelative('/absolute/path')).toBe(false)
})
})

describe('parent directory traversal', () => {
test('should return false for parent directory only', () => {
expect(isSafeRelative('..')).toBe(false)
})

test('should return true for paths starting with ../', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
test('should return true for paths starting with ../', () => {
test('should return false for paths starting with ../', () => {

expect(isSafeRelative('../file.txt')).toBe(false)
})

test('should return true for paths starting with ..\\', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
test('should return true for paths starting with ..\\', () => {
test('should return false for paths starting with ..\\', () => {

expect(isSafeRelative('..\\file.txt')).toBe(false)
})

test('should return true for normalized paths that escape context', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
test('should return true for normalized paths that escape context', () => {
test('should return false for normalized paths that escape context', () => {

expect(isSafeRelative('foo/../../bar')).toBe(false)
})
})

describe('valid relative paths', () => {
test('should return false for simple relative paths', () => {
expect(isSafeRelative('file.txt')).toBe(true)
expect(isSafeRelative('folder/file.txt')).toBe(true)
})

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', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
test('should return false for glob patterns', () => {
test('should return true for glob patterns', () => {

expect(isSafeRelative('*.txt')).toBe(true)
expect(isSafeRelative('**/*.ts')).toBe(true)
expect(isSafeRelative('src/**/*')).toBe(true)
})

test('should return false for hidden files and directories', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
test('should return false for hidden files and directories', () => {
test('should return true for hidden files and directories', () => {

expect(isSafeRelative('.hidden')).toBe(true)
expect(isSafeRelative('.config/settings')).toBe(true)
})
})
})
78 changes: 78 additions & 0 deletions packages/js-sdk/tests/template/utils/normalizePath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { expect, test, describe } from 'vitest'
import { normalizePath } from '../../../src/template/utils'

describe('normalizePath', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a test for a/./../b/

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')
})
})
})
24 changes: 22 additions & 2 deletions packages/python-sdk/e2b/template/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
read_dockerignore,
read_gcp_service_account_json,
get_caller_frame,
is_safe_relative,
normalize_path,
)
from types import TracebackType

Expand Down Expand Up @@ -65,9 +67,27 @@ def copy(
srcs = [src] if isinstance(src, (str, Path)) else src

for src_item in srcs:
normalized_src_item = normalize_path(str(src_item))
normalized_dest = normalize_path(str(dest))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be done outside of the loop


if not is_safe_relative(str(src_item)):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this use the normalized 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),
str(dest),
normalized_src_item,
normalized_dest,
user or "",
pad_octal(mode) if mode else "",
]
Expand Down
Loading