diff --git a/.changeset/api-key-prefix-override.md b/.changeset/api-key-prefix-override.md new file mode 100644 index 0000000000..da78329ac4 --- /dev/null +++ b/.changeset/api-key-prefix-override.md @@ -0,0 +1,6 @@ +--- +'e2b': patch +'@e2b/python-sdk': patch +--- + +Support a custom API key prefix for client-side validation. The prefix defaults to `e2b_` and can be overridden via the `E2B_API_KEY_PREFIX` environment variable or the `apiKeyPrefix` (JS) / `api_key_prefix` (Python) connection option. diff --git a/packages/js-sdk/src/api/index.ts b/packages/js-sdk/src/api/index.ts index afb6fe81de..491bb3343b 100644 --- a/packages/js-sdk/src/api/index.ts +++ b/packages/js-sdk/src/api/index.ts @@ -6,18 +6,25 @@ import { createApiFetch } from './http2' import { ConnectionConfig } from '../connectionConfig' import { AuthenticationError, RateLimitError, SandboxError } from '../errors' import { createApiLogger } from '../logs' +import { escapeRegExp } from '../utils' -const API_KEY_PATTERN = /^e2b_[0-9a-f]+$/ -const API_KEY_EXAMPLE = `e2b_${'0'.repeat(40)}` +export const DEFAULT_API_KEY_PREFIX = 'e2b_' /** - * Validates that an E2B API key has the expected `e2b_` prefix followed by - * hex characters. Throws `AuthenticationError` otherwise. + * Validates that an E2B API key has the expected prefix followed by hex + * characters. Throws `AuthenticationError` otherwise. The prefix defaults to + * `e2b_` and can be overridden via the `E2B_API_KEY_PREFIX` env var or the + * `apiKeyPrefix` connection option. */ -export function validateApiKey(apiKey: string): void { - if (!API_KEY_PATTERN.test(apiKey)) { +export function validateApiKey( + apiKey: string, + prefix: string = DEFAULT_API_KEY_PREFIX +): void { + const pattern = new RegExp(`^${escapeRegExp(prefix)}[0-9a-f]+$`) + if (!pattern.test(apiKey)) { + const example = `${prefix}${'0'.repeat(40)}` throw new AuthenticationError( - `Invalid API key format: expected "e2b_" followed by hex characters (e.g. "${API_KEY_EXAMPLE}"). ` + + `Invalid API key format: expected "${prefix}" followed by hex characters (e.g. "${example}"). ` + 'Visit the API Keys tab at https://e2b.dev/dashboard?tab=keys to get your API key.' ) } @@ -83,7 +90,7 @@ class ApiClient { } if (config.apiKey) { - validateApiKey(config.apiKey) + validateApiKey(config.apiKey, config.apiKeyPrefix) } if (opts?.requireAccessToken && !config.accessToken) { diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index ef034fb3b7..00715275c8 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -21,6 +21,13 @@ export interface ConnectionOpts { * @default E2B_API_KEY // environment variable */ apiKey?: string + /** + * Prefix expected on the E2B API key, used by client-side format validation. + * Override this when your deployment issues API keys with a non-default prefix. + * + * @default E2B_API_KEY_PREFIX // environment variable or `e2b_` + */ + apiKeyPrefix?: string /** * E2B access token to use for authentication. * @@ -189,12 +196,14 @@ export class ConnectionConfig { readonly requestTimeoutMs: number readonly apiKey?: string + readonly apiKeyPrefix: string readonly accessToken?: string readonly headers?: Record constructor(opts?: ConnectionOpts) { this.apiKey = opts?.apiKey || ConnectionConfig.apiKey + this.apiKeyPrefix = opts?.apiKeyPrefix || ConnectionConfig.apiKeyPrefix this.debug = opts?.debug || ConnectionConfig.debug this.domain = opts?.domain || ConnectionConfig.domain this.accessToken = opts?.accessToken || ConnectionConfig.accessToken @@ -231,6 +240,10 @@ export class ConnectionConfig { return getEnvVar('E2B_API_KEY') } + private static get apiKeyPrefix() { + return getEnvVar('E2B_API_KEY_PREFIX') || 'e2b_' + } + private static get accessToken() { return getEnvVar('E2B_ACCESS_TOKEN') } diff --git a/packages/js-sdk/src/utils.ts b/packages/js-sdk/src/utils.ts index 1037ae8100..9dde5fd992 100644 --- a/packages/js-sdk/src/utils.ts +++ b/packages/js-sdk/src/utils.ts @@ -129,6 +129,14 @@ export function shellQuote(s: string): string { return "'" + s.replace(/'/g, "'\\''") + "'" } +/** + * Escape regex metacharacters in a string so it can be safely interpolated + * into a `RegExp` as a literal. + */ +export function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + /** * Prepare data for upload as a BodyInit, optionally gzip-compressed. * When gzip is enabled, compresses the data and returns a Blob. diff --git a/packages/js-sdk/tests/api/validateApiKey.test.ts b/packages/js-sdk/tests/api/validateApiKey.test.ts index c490d1c9d9..fcf80f6900 100644 --- a/packages/js-sdk/tests/api/validateApiKey.test.ts +++ b/packages/js-sdk/tests/api/validateApiKey.test.ts @@ -50,4 +50,39 @@ describe('validateApiKey', () => { ) } }) + + test('accepts a key with a custom prefix', () => { + assert.doesNotThrow(() => + validateApiKey('myorg_' + '0'.repeat(40), 'myorg_') + ) + }) + + test('rejects a key whose prefix does not match the custom prefix', () => { + assert.throws( + () => validateApiKey('e2b_' + '0'.repeat(40), 'myorg_'), + AuthenticationError, + /Invalid API key format/ + ) + }) + + test('custom prefix appears in the error message and example', () => { + try { + validateApiKey('nope', 'myorg_') + assert.fail('expected validateApiKey to throw') + } catch (err) { + assert.instanceOf(err, AuthenticationError) + assert.match((err as Error).message, /myorg_/) + assert.match((err as Error).message, /myorg_0{40}/) + } + }) + + test('escapes regex metacharacters in the prefix', () => { + assert.doesNotThrow(() => + validateApiKey('my.org+' + '0'.repeat(40), 'my.org+') + ) + assert.throws( + () => validateApiKey('myXorgY' + '0'.repeat(40), 'my.org+'), + AuthenticationError + ) + }) }) diff --git a/packages/js-sdk/tests/connectionConfig.test.ts b/packages/js-sdk/tests/connectionConfig.test.ts index f56adf62df..c0002359f0 100644 --- a/packages/js-sdk/tests/connectionConfig.test.ts +++ b/packages/js-sdk/tests/connectionConfig.test.ts @@ -13,6 +13,7 @@ beforeEach(() => { E2B_DOMAIN: process.env.E2B_DOMAIN, E2B_SANDBOX_URL: process.env.E2B_SANDBOX_URL, E2B_DEBUG: process.env.E2B_DEBUG, + E2B_API_KEY_PREFIX: process.env.E2B_API_KEY_PREFIX, } }) @@ -146,6 +147,27 @@ test('sandbox_url stays localhost in debug mode', () => { ) }) +test('apiKeyPrefix defaults to e2b_', () => { + delete process.env.E2B_API_KEY_PREFIX + + const config = new ConnectionConfig() + assert.equal(config.apiKeyPrefix, 'e2b_') +}) + +test('apiKeyPrefix from env var', () => { + process.env.E2B_API_KEY_PREFIX = 'myorg_' + + const config = new ConnectionConfig() + assert.equal(config.apiKeyPrefix, 'myorg_') +}) + +test('apiKeyPrefix in args has priority over env var', () => { + process.env.E2B_API_KEY_PREFIX = 'fromenv_' + + const config = new ConnectionConfig({ apiKeyPrefix: 'fromargs_' }) + assert.equal(config.apiKeyPrefix, 'fromargs_') +}) + test('getSignal returns user signal when no timeout is set', () => { const config = new ConnectionConfig({ requestTimeoutMs: 0 }) const controller = new AbortController() diff --git a/packages/python-sdk/e2b/api/__init__.py b/packages/python-sdk/e2b/api/__init__.py index 07830bc127..226b3a43ba 100644 --- a/packages/python-sdk/e2b/api/__init__.py +++ b/packages/python-sdk/e2b/api/__init__.py @@ -75,18 +75,21 @@ def status_code(self) -> int: ... def content(self) -> Union[str, bytes]: ... -_API_KEY_PATTERN = re.compile(r"\Ae2b_[0-9a-f]+\Z") -_API_KEY_EXAMPLE = "e2b_" + "0" * 40 +DEFAULT_API_KEY_PREFIX = "e2b_" -def validate_api_key(api_key: str) -> None: - """Validate that an E2B API key has the expected ``e2b_`` prefix - followed by hex characters. Raises ``AuthenticationException`` otherwise. +def validate_api_key(api_key: str, prefix: str = DEFAULT_API_KEY_PREFIX) -> None: + """Validate that an E2B API key has the expected prefix followed by hex + characters. Raises ``AuthenticationException`` otherwise. The prefix + defaults to ``e2b_`` and can be overridden via the ``E2B_API_KEY_PREFIX`` + env var or the ``api_key_prefix`` connection option. """ - if not _API_KEY_PATTERN.match(api_key): + pattern = re.compile(rf"\A{re.escape(prefix)}[0-9a-f]+\Z") + if not pattern.match(api_key): + example = f"{prefix}{'0' * 40}" raise AuthenticationException( - 'Invalid API key format: expected "e2b_" followed by hex ' - f'characters (e.g. "{_API_KEY_EXAMPLE}"). ' + f'Invalid API key format: expected "{prefix}" followed by hex ' + f'characters (e.g. "{example}"). ' "Visit the API Keys tab at https://e2b.dev/dashboard?tab=keys to get your API key." ) @@ -126,7 +129,7 @@ def __init__( token = config.api_key if config.api_key is not None: - validate_api_key(config.api_key) + validate_api_key(config.api_key, config.api_key_prefix) if require_access_token: if config.access_token is None: diff --git a/packages/python-sdk/e2b/connection_config.py b/packages/python-sdk/e2b/connection_config.py index e24cedad27..7b99e61a58 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -33,6 +33,10 @@ class ApiParams(TypedDict, total=False): api_key: Optional[str] """E2B API Key to use for authentication, defaults to `E2B_API_KEY` environment variable.""" + api_key_prefix: Optional[str] + """Prefix expected on the E2B API key for client-side format validation, + defaults to `E2B_API_KEY_PREFIX` environment variable or `e2b_`.""" + domain: Optional[str] """E2B domain to use for authentication, defaults to `E2B_DOMAIN` environment variable.""" @@ -68,6 +72,10 @@ def _debug(): def _api_key(): return os.getenv("E2B_API_KEY") + @staticmethod + def _api_key_prefix(): + return os.getenv("E2B_API_KEY_PREFIX") or "e2b_" + @staticmethod def _api_url(): return os.getenv("E2B_API_URL") @@ -85,6 +93,7 @@ def __init__( domain: Optional[str] = None, debug: Optional[bool] = None, api_key: Optional[str] = None, + api_key_prefix: Optional[str] = None, api_url: Optional[str] = None, sandbox_url: Optional[str] = None, access_token: Optional[str] = None, @@ -97,6 +106,7 @@ def __init__( self.domain = domain or ConnectionConfig._domain() self.debug = debug or ConnectionConfig._debug() self.api_key = api_key or ConnectionConfig._api_key() + self.api_key_prefix = api_key_prefix or ConnectionConfig._api_key_prefix() self.access_token = access_token or ConnectionConfig._access_token() self.headers = {**(headers or {}), **(api_headers or {})} self.headers["User-Agent"] = f"e2b-python-sdk/{package_version}" @@ -191,6 +201,7 @@ def get_api_params( api_headers = opts.get("api_headers") request_timeout = opts.get("request_timeout") api_key = opts.get("api_key") + api_key_prefix = opts.get("api_key_prefix") api_url = opts.get("api_url") domain = opts.get("domain") debug = opts.get("debug") @@ -205,6 +216,9 @@ def get_api_params( return dict( ApiParams( api_key=api_key if api_key is not None else self.api_key, + api_key_prefix=api_key_prefix + if api_key_prefix is not None + else self.api_key_prefix, api_url=api_url if api_url is not None else self.api_url, domain=domain if domain is not None else self.domain, debug=debug if debug is not None else self.debug, diff --git a/packages/python-sdk/tests/test_connection_config.py b/packages/python-sdk/tests/test_connection_config.py index 85f108fe8b..358debe928 100644 --- a/packages/python-sdk/tests/test_connection_config.py +++ b/packages/python-sdk/tests/test_connection_config.py @@ -28,6 +28,27 @@ def test_api_url_has_correct_priority(monkeypatch): assert config.api_url == "http://localhost:8080" +def test_api_key_prefix_defaults_to_e2b(monkeypatch): + monkeypatch.delenv("E2B_API_KEY_PREFIX", raising=False) + + config = ConnectionConfig() + assert config.api_key_prefix == "e2b_" + + +def test_api_key_prefix_from_env_var(monkeypatch): + monkeypatch.setenv("E2B_API_KEY_PREFIX", "myorg_") + + config = ConnectionConfig() + assert config.api_key_prefix == "myorg_" + + +def test_api_key_prefix_arg_has_priority_over_env_var(monkeypatch): + monkeypatch.setenv("E2B_API_KEY_PREFIX", "fromenv_") + + config = ConnectionConfig(api_key_prefix="fromargs_") + assert config.api_key_prefix == "fromargs_" + + def test_sandbox_url_uses_stable_host_for_supported_domain(): config = ConnectionConfig(domain="e2b.app") diff --git a/packages/python-sdk/tests/test_validate_api_key.py b/packages/python-sdk/tests/test_validate_api_key.py index 28e5fa5f55..117973e7e6 100644 --- a/packages/python-sdk/tests/test_validate_api_key.py +++ b/packages/python-sdk/tests/test_validate_api_key.py @@ -36,3 +36,25 @@ def test_error_message_includes_example_token(test_api_key): with pytest.raises(AuthenticationException) as exc_info: validate_api_key("nope") assert test_api_key in str(exc_info.value) + + +def test_accepts_custom_prefix(): + validate_api_key("myorg_" + "0" * 40, prefix="myorg_") + + +def test_rejects_when_prefix_does_not_match_custom_prefix(): + with pytest.raises(AuthenticationException, match=r"Invalid API key format"): + validate_api_key("e2b_" + "0" * 40, prefix="myorg_") + + +def test_custom_prefix_appears_in_error_message(): + with pytest.raises(AuthenticationException) as exc_info: + validate_api_key("nope", prefix="myorg_") + assert "myorg_" in str(exc_info.value) + assert "myorg_" + "0" * 40 in str(exc_info.value) + + +def test_escapes_regex_metacharacters_in_prefix(): + validate_api_key("my.org+" + "0" * 40, prefix="my.org+") + with pytest.raises(AuthenticationException, match=r"Invalid API key format"): + validate_api_key("myXorgY" + "0" * 40, prefix="my.org+")