Skip to content

Commit 35c0e15

Browse files
committed
Address review comments for worker sandbox PR
- Use atomic_init() for proper C11 atomic initialization - Add mutex lock in sandbox_policy_set_enabled for thread safety - Fix memory leaks in import whitelist allocation (cleanup on failure) - Fix memory leak in sandbox_policy_update when parse fails - Always sync allow_imports so omitting it clears existing whitelist - Preserve preset flags when explicit block list provided (use |= semantics) - Remove unused audit_handler field from sandbox_policy_t - Add os.fork and os.posix_spawn to subprocess blocking docs - Rename test functions to match their actual behavior - Add temp file cleanup in test_sandbox_set_policy
1 parent 5e6e26f commit 35c0e15

File tree

5 files changed

+81
-48
lines changed

5 files changed

+81
-48
lines changed

c_src/py_sandbox.c

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ sandbox_policy_t *sandbox_policy_new(void) {
399399
memset(policy, 0, sizeof(sandbox_policy_t));
400400
pthread_mutex_init(&policy->mutex, NULL);
401401
policy->enabled = false;
402-
policy->block_flags = 0;
402+
atomic_init(&policy->block_flags, 0);
403403
policy->allowed_imports = NULL;
404404
policy->allowed_imports_count = 0;
405405
policy->log_events = false;
@@ -477,13 +477,15 @@ int parse_sandbox_options(ErlNifEnv *env, ERL_NIF_TERM opts, sandbox_policy_t *p
477477
}
478478
}
479479

480-
/* Check for block list */
480+
/* Check for block list
481+
* Note: An explicit block list is combined with any preset flags using OR.
482+
* This allows extending a preset with additional blocked operations. */
481483
key = enif_make_atom(env, "block");
482484
if (enif_get_map_value(env, opts, key, &value)) {
483485
unsigned int list_len;
484486
if (enif_get_list_length(env, value, &list_len)) {
485487
ERL_NIF_TERM head, tail = value;
486-
policy->block_flags = 0;
488+
/* Don't reset block_flags - preserve preset flags and merge with block list */
487489
policy->enabled = true; /* Enable if block list provided */
488490

489491
while (enif_get_list_cell(env, tail, &head, &tail)) {
@@ -525,20 +527,36 @@ int parse_sandbox_options(ErlNifEnv *env, ERL_NIF_TERM opts, sandbox_policy_t *p
525527
ErlNifBinary bin;
526528
if (enif_inspect_binary(env, head, &bin)) {
527529
char *import_name = enif_alloc(bin.size + 1);
528-
if (import_name != NULL) {
529-
memcpy(import_name, bin.data, bin.size);
530-
import_name[bin.size] = '\0';
531-
policy->allowed_imports[policy->allowed_imports_count++] = import_name;
530+
if (import_name == NULL) {
531+
/* Cleanup previously allocated imports on failure */
532+
for (size_t i = 0; i < policy->allowed_imports_count; i++) {
533+
enif_free(policy->allowed_imports[i]);
534+
}
535+
enif_free(policy->allowed_imports);
536+
policy->allowed_imports = NULL;
537+
policy->allowed_imports_count = 0;
538+
return -1;
532539
}
540+
memcpy(import_name, bin.data, bin.size);
541+
import_name[bin.size] = '\0';
542+
policy->allowed_imports[policy->allowed_imports_count++] = import_name;
533543
} else {
534544
/* Try as atom */
535545
char atom_buf[256];
536546
if (enif_get_atom(env, head, atom_buf, sizeof(atom_buf), ERL_NIF_LATIN1)) {
537547
char *import_name = enif_alloc(strlen(atom_buf) + 1);
538-
if (import_name != NULL) {
539-
strcpy(import_name, atom_buf);
540-
policy->allowed_imports[policy->allowed_imports_count++] = import_name;
548+
if (import_name == NULL) {
549+
/* Cleanup previously allocated imports on failure */
550+
for (size_t i = 0; i < policy->allowed_imports_count; i++) {
551+
enif_free(policy->allowed_imports[i]);
552+
}
553+
enif_free(policy->allowed_imports);
554+
policy->allowed_imports = NULL;
555+
policy->allowed_imports_count = 0;
556+
return -1;
541557
}
558+
strcpy(import_name, atom_buf);
559+
policy->allowed_imports[policy->allowed_imports_count++] = import_name;
542560
}
543561
}
544562
}
@@ -565,7 +583,9 @@ int parse_sandbox_options(ErlNifEnv *env, ERL_NIF_TERM opts, sandbox_policy_t *p
565583
*/
566584
void sandbox_policy_set_enabled(sandbox_policy_t *policy, bool enabled) {
567585
if (policy != NULL) {
586+
pthread_mutex_lock(&policy->mutex);
568587
policy->enabled = enabled;
588+
pthread_mutex_unlock(&policy->mutex);
569589
}
570590
}
571591

@@ -592,6 +612,13 @@ int sandbox_policy_update(ErlNifEnv *env, sandbox_policy_t *policy, ERL_NIF_TERM
592612
temp.log_events = policy->log_events;
593613

594614
if (parse_sandbox_options(env, opts, &temp) < 0) {
615+
/* Clean up any imports allocated in temp before returning */
616+
if (temp.allowed_imports != NULL) {
617+
for (size_t i = 0; i < temp.allowed_imports_count; i++) {
618+
enif_free(temp.allowed_imports[i]);
619+
}
620+
enif_free(temp.allowed_imports);
621+
}
595622
pthread_mutex_unlock(&policy->mutex);
596623
return -1;
597624
}
@@ -601,18 +628,16 @@ int sandbox_policy_update(ErlNifEnv *env, sandbox_policy_t *policy, ERL_NIF_TERM
601628
policy->enabled = temp.enabled;
602629
policy->log_events = temp.log_events;
603630

604-
/* Update import whitelist */
605-
if (temp.allowed_imports != NULL) {
606-
/* Free old whitelist */
607-
if (policy->allowed_imports != NULL) {
608-
for (size_t i = 0; i < policy->allowed_imports_count; i++) {
609-
enif_free(policy->allowed_imports[i]);
610-
}
611-
enif_free(policy->allowed_imports);
631+
/* Update import whitelist - always sync from temp so omitting
632+
* allow_imports in an update clears any existing whitelist */
633+
if (policy->allowed_imports != NULL) {
634+
for (size_t i = 0; i < policy->allowed_imports_count; i++) {
635+
enif_free(policy->allowed_imports[i]);
612636
}
613-
policy->allowed_imports = temp.allowed_imports;
614-
policy->allowed_imports_count = temp.allowed_imports_count;
637+
enif_free(policy->allowed_imports);
615638
}
639+
policy->allowed_imports = temp.allowed_imports;
640+
policy->allowed_imports_count = temp.allowed_imports_count;
616641

617642
pthread_mutex_unlock(&policy->mutex);
618643
return 0;

c_src/py_sandbox.h

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,14 @@ struct sandbox_policy_t {
119119
/** @brief Number of entries in allowed_imports */
120120
size_t allowed_imports_count;
121121

122-
/** @brief Whether to send audit events to Erlang handler */
122+
/**
123+
* @brief Whether to log audit events (reserved for future use)
124+
*
125+
* When true, blocked events will be logged. Event forwarding to Erlang
126+
* is planned for a future version.
127+
*/
123128
bool log_events;
124129

125-
/** @brief PID of Erlang process to receive audit events */
126-
ErlNifPid audit_handler;
127-
128130
/** @brief Mutex for protecting policy updates */
129131
pthread_mutex_t mutex;
130132
};

docs/sandbox.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ py_nif:worker_destroy(Worker).
3737
### Strict Preset
3838

3939
The `strict` preset blocks:
40-
- `subprocess`: `subprocess.Popen`, `os.system`, `os.exec*`, `os.spawn*`, `os.popen`
40+
- `subprocess`: `subprocess.Popen`, `os.system`, `os.exec*`, `os.spawn*`, `os.fork`, `os.posix_spawn`, `os.popen`
4141
- `network`: `socket.*` operations
4242
- `ctypes`: ctypes module (memory access)
4343
- `file_write`: `open()` with write/append modes (`w`, `a`, `x`, `+`)
@@ -67,7 +67,7 @@ For fine-grained control, specify exactly which operations to block:
6767
|------|--------|
6868
| `file_write` | `open()` with write/append modes |
6969
| `file_read` | All file read operations |
70-
| `subprocess` | `subprocess.*`, `os.exec*`, `os.spawn*`, `os.popen` |
70+
| `subprocess` | `subprocess.*`, `os.exec*`, `os.spawn*`, `os.fork`, `os.posix_spawn`, `os.popen` |
7171
| `network` | `socket.*` operations |
7272
| `ctypes` | ctypes module (memory access) |
7373
| `import` | Non-whitelisted imports |

src/py.erl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -841,7 +841,7 @@ ctx_exec(#py_ctx{ref = CtxRef}, Code) ->
841841
%% '''
842842
%%
843843
%% With strict preset, the following are blocked:
844-
%% - subprocess: subprocess.*, os.system, os.exec*, os.spawn*, os.popen
844+
%% - subprocess: subprocess.*, os.system, os.exec*, os.spawn*, os.fork, os.posix_spawn, os.popen
845845
%% - network: socket.*
846846
%% - ctypes: ctypes module (memory access)
847847
%% - file_write: open() with write/append modes

test/py_sandbox_SUITE.erl

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@
2828
test_call_sandboxed_basic/1,
2929
test_call_sandboxed_strict/1,
3030
test_call_sandboxed_with_kwargs/1,
31-
%% Escape attempt tests
32-
test_subprocess_escape_attempts/1,
33-
test_network_escape_attempts/1
31+
%% Additional tests
32+
test_file_write_escape_attempts/1,
33+
test_file_read_allowed/1
3434
]).
3535

3636
all() ->
@@ -49,8 +49,8 @@ all() ->
4949
test_call_sandboxed_basic,
5050
test_call_sandboxed_strict,
5151
test_call_sandboxed_with_kwargs,
52-
test_subprocess_escape_attempts,
53-
test_network_escape_attempts
52+
test_file_write_escape_attempts,
53+
test_file_read_allowed
5454
].
5555

5656
init_per_suite(Config) ->
@@ -229,17 +229,23 @@ test_sandbox_enable_disable(_Config) ->
229229

230230
%% Test sandbox_set_policy to change policy
231231
test_sandbox_set_policy(_Config) ->
232-
{ok, W} = py_nif:worker_new(),
233-
%% Initially no sandbox - file write allowed
234-
ok = py_nif:worker_exec(W, <<"f = open('/tmp/policy_test_before.txt', 'w')\nf.close()">>),
235-
%% Set policy to block file_write
236-
ok = py_nif:sandbox_set_policy(W, #{enabled => true, block => [file_write]}),
237-
%% Now file write should be blocked
238-
Result = py_nif:worker_exec(W, <<"g = open('/tmp/policy_test_after.txt', 'w')">>),
239-
py_nif:worker_destroy(W),
240-
case Result of
241-
{error, {'PermissionError', _}} -> ok;
242-
Other -> ct:fail({should_be_blocked_after_policy_change, Other})
232+
try
233+
{ok, W} = py_nif:worker_new(),
234+
%% Initially no sandbox - file write allowed
235+
ok = py_nif:worker_exec(W, <<"f = open('/tmp/policy_test_before.txt', 'w')\nf.close()">>),
236+
%% Set policy to block file_write
237+
ok = py_nif:sandbox_set_policy(W, #{enabled => true, block => [file_write]}),
238+
%% Now file write should be blocked
239+
Result = py_nif:worker_exec(W, <<"g = open('/tmp/policy_test_after.txt', 'w')">>),
240+
py_nif:worker_destroy(W),
241+
case Result of
242+
{error, {'PermissionError', _}} -> ok;
243+
Other -> ct:fail({should_be_blocked_after_policy_change, Other})
244+
end
245+
after
246+
%% Clean up test files
247+
_ = file:delete("/tmp/policy_test_before.txt"),
248+
_ = file:delete("/tmp/policy_test_after.txt")
243249
end.
244250

245251
%% Test sandbox_get_policy
@@ -284,11 +290,11 @@ test_call_sandboxed_with_kwargs(_Config) ->
284290
ok.
285291

286292
%%% ============================================================================
287-
%%% Test Cases - Escape Attempts
293+
%%% Test Cases - Additional File Operation Tests
288294
%%% ============================================================================
289295

290-
%% Test various file write escape attempts
291-
test_subprocess_escape_attempts(_Config) ->
296+
%% Test that various file write modes are blocked
297+
test_file_write_escape_attempts(_Config) ->
292298
%% Test each write attempt with a fresh worker
293299
%% Using file operations since they are reliably audited
294300
Attempts = [
@@ -309,8 +315,8 @@ test_subprocess_escape_attempts(_Config) ->
309315
end, Attempts),
310316
ok.
311317

312-
%% Test that file reads are allowed
313-
test_network_escape_attempts(_Config) ->
318+
%% Test that file reads are allowed with strict preset
319+
test_file_read_allowed(_Config) ->
314320
{ok, W} = py_nif:worker_new(#{sandbox => #{preset => strict}}),
315321
%% File read should be allowed (strict only blocks write)
316322
Result = py_nif:worker_exec(W, <<"data = open('/etc/hosts', 'r').read()[:10]">>),

0 commit comments

Comments
 (0)