Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b5eb2a7
fix: implement missing auto-escaping in Command class
jaredhendrickson13 Aug 6, 2026
5055f4f
chore: CommandPrompt should not auto-escape
jaredhendrickson13 Aug 6, 2026
0a4eb7d
chore: replace outlier exec calls with Command calls
jaredhendrickson13 Aug 6, 2026
06d46d9
fix: enforce character validation in FilterNameValidator
jaredhendrickson13 Aug 6, 2026
e5221c7
fix: add sensitive flag to several model fields
jaredhendrickson13 Aug 6, 2026
b812d56
style: run prettier on changed files
jaredhendrickson13 Aug 6, 2026
e3e039a
chore: don't make presharedkey write only
jaredhendrickson13 Aug 6, 2026
787b5ef
test: replace usage of shell_exec in tests with Command
jaredhendrickson13 Aug 6, 2026
ca05fa9
chore: replace redundant TestCase::run_command method with Command calls
jaredhendrickson13 Aug 6, 2026
7f1e6a9
chore: optimize imports for models
jaredhendrickson13 Aug 6, 2026
eee85bf
style: run prettier on changed files
jaredhendrickson13 Aug 6, 2026
a4161a3
test: use redirect param instead of redirect literal
jaredhendrickson13 Aug 6, 2026
ecb06a7
test: use pipe parameter instead of pipe literal
jaredhendrickson13 Aug 6, 2026
52cbc73
test: sanitization wraps args in single-quotes, not individual char e…
jaredhendrickson13 Aug 6, 2026
10a3084
test: use redirect and pipe literals for LogTraits tests
jaredhendrickson13 Aug 6, 2026
f716a68
fix: derive acme results from issue log
jaredhendrickson13 Aug 6, 2026
cae11cd
style: run prettier on changed files
jaredhendrickson13 Aug 6, 2026
41a3f58
test: check table status code in firewall alias tests
jaredhendrickson13 Aug 6, 2026
b3fea0d
style: run prettier on changed files
jaredhendrickson13 Aug 6, 2026
b46d10b
fix(RESTAPIKey): use hash_equals for key comparison
jaredhendrickson13 Aug 6, 2026
04d9f82
chore: opt for static analysis of requested auth method
jaredhendrickson13 Aug 6, 2026
f10a916
chore: require both a username and a password to request basic auth
jaredhendrickson13 Aug 6, 2026
98d3aa5
chore: guard against empty usernames in Auth class
jaredhendrickson13 Aug 6, 2026
275bf63
chore: guard against empty usernames in JWTAuth class
jaredhendrickson13 Aug 6, 2026
311b651
chore: use PHP 8+ safe default syntax
jaredhendrickson13 Aug 6, 2026
448a0d3
style: run prettier on changed files
jaredhendrickson13 Aug 6, 2026
2b549f2
chore: prevent redundant validation during deletions
jaredhendrickson13 Aug 6, 2026
1908d8b
test: invert context of when basic auth is requested without username…
jaredhendrickson13 Aug 6, 2026
e2f3bcd
style: run prettier on changed files
jaredhendrickson13 Aug 6, 2026
0268aa7
test: correct accidental false assertion for valid basic auth
jaredhendrickson13 Aug 7, 2026
77500f7
ci: do not build for CE 2.8.0
jaredhendrickson13 Aug 7, 2026
fcc9b8f
chore: change validator expansion method for StringFields
jaredhendrickson13 Aug 7, 2026
640aedd
Merge branch 'master' into next_patch
jaredhendrickson13 Aug 7, 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: 0 additions & 6 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,6 @@ jobs:
strategy:
matrix:
include:
- PFSENSE_VERSION: pfSense-2.8.0-RELEASE
FREEBSD_ID: freebsd15
- PFSENSE_VERSION: pfSense-2.8.1-RELEASE
FREEBSD_ID: freebsd15
steps:
Expand Down Expand Up @@ -107,8 +105,6 @@ jobs:
strategy:
matrix:
include:
- PFSENSE_VERSION: pfSense-2.8.0-RELEASE
FREEBSD_ID: freebsd15
- PFSENSE_VERSION: pfSense-2.8.1-RELEASE
FREEBSD_ID: freebsd15
steps:
Expand Down Expand Up @@ -136,8 +132,6 @@ jobs:
strategy:
matrix:
include:
- PFSENSE_VERSION: pfSense-2.8.0-RELEASE
FREEBSD_ID: freebsd15
- PFSENSE_VERSION: pfSense-2.8.1-RELEASE
FREEBSD_ID: freebsd15

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ class BasicAuth extends Auth {
*/
public function _authenticate(): bool {
# Obtain the username and password from the client via Basic authentication
$this->username = $_SERVER['PHP_AUTH_USER'] ?: '';
$password = $_SERVER['PHP_AUTH_PW'] ?: '';
$this->username = $_SERVER['PHP_AUTH_USER'] ?? '';
$password = $_SERVER['PHP_AUTH_PW'] ?? '';

# Authenticate against the local user database and return the result
return (bool) authenticate_user($this->username, $password);
Expand All @@ -39,7 +39,7 @@ class BasicAuth extends Auth {
* Checks if the remote client is requesting BasicAuth by checking for the necessary headers.
*/
public function is_requested(): bool {
return $_SERVER['PHP_AUTH_USER'] or $_SERVER['PHP_AUTH_PW'];
return $_SERVER['PHP_AUTH_USER'] and $_SERVER['PHP_AUTH_PW'];
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,20 @@ class JWTAuth extends Auth {
$decoded_jwt = RESTAPIJWT::decode($this->get_auth_key(identifier: 'bearer'));

# Check that the JWT from our Authorization header is valid
if ($decoded_jwt) {
if ($decoded_jwt and !empty($decoded_jwt['data'])) {
# Set the username of the authenticating client embedded in the decoded JWT data payload.
$this->username = $decoded_jwt['data'];
return true;
}

return false;
}

/**
* Checks if the client is requesting JWT authentication by detecting an Authorization: Bearer
* header. Credential validation is intentionally deferred to _authenticate().
*/
public function is_requested(): bool {
return !empty($this->get_auth_key(identifier: 'bearer'));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ class KeyAuth extends Auth {
*/
public array $security_scheme = ['type' => 'apiKey', 'in' => 'header', 'name' => 'x-api-key'];

/**
* Checks if the client is requesting key authentication by detecting the x-api-key header.
* Credential validation is intentionally deferred to _authenticate().
*/
public function is_requested(): bool {
return !empty($_SERVER['HTTP_X_API_KEY'] ?? '');
}

/**
* Performs REST API key authentication and obtains the username of the user who owns the provided key.
* @return bool Returns true if match for this client's key found a match stored in config, returns false
Expand Down
5 changes: 5 additions & 0 deletions pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Auth.inc
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,11 @@ class Auth {
* @return bool `true` if the user is disabled, `false` if it is not.
*/
public static function is_user_enabled(string $username): bool {
# If username is somehow empty, consider them disabled
if (empty($username)) {
return false;
}

return User::query(name: $username, disabled: false)->exists();
}

Expand Down
94 changes: 92 additions & 2 deletions pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Command.inc
Original file line number Diff line number Diff line change
Expand Up @@ -33,29 +33,119 @@ class Command {
*/
public bool $trim_whitespace = false;

/**
* @var bool $escape Whether to automatically escape each token in the command string before execution.
* Defaults to true. Set to false only when the caller has already performed its own escaping or when
* shell features such as pipes, redirects, or glob expansion are intentionally required.
*/
public bool $escape = true;

/**
* @var string $pipe An optional command string to pipe the output of $command into. When set, the shell
* pipeline `<command> | <pipe>` is constructed. The pipe command is escaped independently according to
* $escape_pipe. Use this instead of embedding `|` in the raw $command string, which would be neutralised
* by auto-escaping.
*/
public string $pipe = '';

/**
* @var bool $escape_pipe Whether to automatically escape the tokens of the $pipe command string. Defaults
* to true. Set to false when the pipe command contains shell features (e.g. `grep "some pattern"`) that
* must be passed verbatim.
*/
public bool $escape_pipe = true;

/**
* Defines the Command object including the shell command to execute and optional modifiers. Note: By default,
* the command output will redirect stderr to stdout so error message will be included in the output.
* @param string $command The shell command to execute.
* @param bool $trim_whitespace Remove excessive whitespace from the command output.
* @param string $redirect An optional shell redirect to append to the end of the $command.
* @param bool $escape Automatically escape each token in the command string before execution. Defaults to
* true. Set to false only when the caller has already performed its own escaping or when shell features
* such as pipes, redirects, or glob expansion are intentionally required.
* @param string $pipe An optional command string to pipe the output of $command into. Escaped independently
* according to $escape_pipe.
* @param bool $escape_pipe Whether to automatically escape the tokens of the $pipe command. Defaults to true.
* @return Command Returns this object containing the results of the executed command. Note: the object returned
* cannot be used to initiate new commands. A new Command object should be created for any additional commands.
*/
public function __construct(string $command, bool $trim_whitespace = false, string $redirect = '2>&1') {
public function __construct(
string $command,
bool $trim_whitespace = false,
string $redirect = '2>&1',
bool $escape = true,
string $pipe = '',
bool $escape_pipe = true,
) {
$this->command = $command;
$this->trim_whitespace = $trim_whitespace;
$this->redirect = $redirect;
$this->escape = $escape;
$this->pipe = $pipe;
$this->escape_pipe = $escape_pipe;
$this->run_command();
return $this;
}

/**
* Parses a raw command string into an array of argument tokens, respecting single- and double-quoted
* substrings so that spaces inside quotes are not treated as delimiters. Surrounding quotes are stripped
* from each token so that the value can be re-escaped uniformly by escape_command().
* @param string $command The raw command string to tokenize.
* @return array<string> An ordered list of unquoted token strings.
*/
public static function tokenize_command(string $command): array {
$tokens = [];

# Match single-quoted strings, double-quoted strings, or unquoted non-whitespace sequences
preg_match_all("/'[^']*'|\"[^\"]*\"|[^\s]+/", $command, $matches);

foreach ($matches[0] as $token) {
# Strip surrounding single quotes
if (str_starts_with($token, "'") && str_ends_with($token, "'")) {
$tokens[] = substr($token, 1, -1);
}
# Strip surrounding double quotes
elseif (str_starts_with($token, '"') && str_ends_with($token, '"')) {
$tokens[] = substr($token, 1, -1);
}
# Unquoted token — use as-is
else {
$tokens[] = $token;
}
}

return $tokens;
}

/**
* Escapes a raw command string by tokenizing it (respecting quoted substrings) and applying
* escapeshellarg() to every token before re-joining with spaces. This prevents command injection
* regardless of whether the original string contained pre-quoted or unquoted arguments.
* @param string $command The raw command string to escape.
* @return string The fully escaped command string ready for exec().
*/
public static function escape_command(string $command): string {
$tokens = self::tokenize_command($command);
$escaped = array_map('escapeshellarg', $tokens);
return implode(' ', $escaped);
}

/**
* Executes the assigned $command. The $output and $result_code properties will be set after running
* this method.
*/
private function run_command(): void {
exec(command: "$this->command $this->redirect", output: $raw_output, result_code: $this->result_code);
$safe_command = $this->escape ? self::escape_command($this->command) : $this->command;

# Append a pipe stage when one is specified, escaping it independently
if ($this->pipe !== '') {
$safe_pipe = $this->escape_pipe ? self::escape_command($this->pipe) : $this->pipe;
$safe_command = "$safe_command | $safe_pipe";
}

exec(command: "$safe_command $this->redirect", output: $raw_output, result_code: $this->result_code);
$this->output = implode(PHP_EOL, $raw_output);

# Normalize output's whitespace if requested
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1299,7 +1299,7 @@ class Endpoint {
}

# Otherwise, delete a single object
$this->model->from_representation(data: $this->request_data);
$this->model->from_representation(id: $this->request_data['id'], parent_id: $this->request_data['parent_id']);
return $this->model->delete(apply: $this->request_data['apply'] === true);
}

Expand Down
21 changes: 0 additions & 21 deletions pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/TestCase.inc
Original file line number Diff line number Diff line change
Expand Up @@ -130,27 +130,6 @@ class TestCase {
}
}

/**
* Runs a shell command and returns its output and return code.
* @param string $command The command to execute.
* @param bool $trim_whitespace Remove excess whitespace from the command output. This is sometimes helpful when
* the output of commands that do not have consistent whitespace formatting.
* @return array An array where the `output` key contains the commands output and the `result_code` key contains the
* resulting result code of the command.
*/
function run_command(string $command, bool $trim_whitespace = false): array {
$results = ['output' => null, 'code' => null];
exec(command: "$command 2>/dev/null", output: $results['output'], result_code: $results['result_code']);
$results['output'] = implode(PHP_EOL, $results['output']);

# Normalize output's whitespace if requested
if ($trim_whitespace) {
$results['output'] = preg_replace('/\s+/', ' ', $results['output']);
}

return $results;
}

/**
* Sets up the test case before tests are run. This can be overridden by your TestCase to setup shared resources
* required for your tests.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace RESTAPI\Dispatchers;

use RESTAPI\Core\Command;
use RESTAPI\Core\Dispatcher;
use RESTAPI\Responses\ServerError;

Expand Down Expand Up @@ -37,14 +38,23 @@ class ACMECertificateIssueDispatcher extends Dispatcher {
result_log: '',
);

# pfSense prints the output of the acme.sh instead of returning it, start output buffering to capture it
ob_start();
# Record the current byte size of the acme.sh issue log before issuance so we can
# extract only the new content appended during this run.
$log_file = "/tmp/acme/{$arguments['certificate']}/acme_issuecert.log";
$pre_size = file_exists($log_file) ? filesize($log_file) : 0;

# Issue the ACME certificate
\pfsense_pkg\acme\issue_certificate(id: $arguments['certificate'], force: true, renew: false);

# Log the results of the ACME certificate issue
$result = ob_get_clean();
# Extract only the bytes appended to the log since before the issuance started.
# tail -c +N outputs from byte N onward (1-indexed), so +($pre_size+1) skips
# all pre-existing content and returns only what was written during this run.
$start_byte = $pre_size + 1;
if (file_exists($log_file) && filesize($log_file) >= $start_byte) {
$result = (new Command("/usr/bin/tail -c +$start_byte $log_file"))->output;
} else {
$result = '';
}

# Replace the issuance result file
$this->set_issuance_result(
Expand All @@ -55,7 +65,7 @@ class ACMECertificateIssueDispatcher extends Dispatcher {
);

# Wait a bit to ensure the issue log is written before proceeding
sleep(1);
sleep(5);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace RESTAPI\Dispatchers;

use RESTAPI\Core\Command;
use RESTAPI\Core\Dispatcher;
use RESTAPI\Responses\ServerError;

Expand Down Expand Up @@ -37,14 +38,23 @@ class ACMECertificateRenewDispatcher extends Dispatcher {
result_log: '',
);

# pfSense prints the output of the acme.sh instead of returning it, start output buffering to capture it
ob_start();
# Record the current byte size of the acme issue log before renewal so we can
# extract only the new content appended during this run.
$log_file = "/tmp/acme/{$arguments['certificate']}/acme_issuecert.log";
$pre_size = file_exists($log_file) ? filesize($log_file) : 0;

# Issue the ACME certificate
# Renew the ACME certificate
\pfsense_pkg\acme\issue_certificate(id: $arguments['certificate'], force: true, renew: true);

# Log the results of the ACME certificate renewal
$result = ob_get_clean();
# Extract only the bytes appended to the log since before the renewal started.
# tail -c +N outputs from byte N onward (1-indexed), so +($pre_size+1) skips
# all pre-existing content and returns only what was written during this run.
$start_byte = $pre_size + 1;
if (file_exists($log_file) && filesize($log_file) >= $start_byte) {
$result = (new Command("/usr/bin/tail -c +$start_byte $log_file"))->output;
} else {
$result = '';
}

# Replace the renewal result file
$this->set_renewal_result(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ class StringField extends Field {
internal_namespace: $internal_namespace,
referenced_by: $referenced_by,
conditions: $conditions,
validators: $validators + [
validators: [
...$validators,
new RESTAPI\Validators\LengthValidator(minimum: $minimum_length, maximum: $maximum_length),
],
help_text: $help_text,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ namespace RESTAPI\Models;

use RESTAPI\Core\Model;
use RESTAPI\Fields\Base64Field;
use RESTAPI\Fields\StringField;
use RESTAPI\Fields\DateTimeField;
use RESTAPI\Fields\IntegerField;
use RESTAPI\Fields\StringField;
use RESTAPI\Fields\UIDField;
use RESTAPI\Responses\ForbiddenError;
use RESTAPI\Responses\ValidationError;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ namespace RESTAPI\Models;
use RESTAPI\Core\Model;
use RESTAPI\Fields\Base64Field;
use RESTAPI\Fields\BooleanField;
use RESTAPI\Fields\ForeignModelField;
use RESTAPI\Fields\IntegerField;
use RESTAPI\Fields\StringField;
use RESTAPI\Fields\UIDField;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class CommandPrompt extends Model {
* Execute the requested command and populate the output and result code.
*/
public function _create(): void {
$cmd = new Command($this->command->value);
$cmd = new Command($this->command->value, escape: false);
$this->output->value = $cmd->output;
$this->result_code->value = $cmd->result_code;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
namespace RESTAPI\Models;

use RESTAPI;
use RESTAPI\Core\Command;
use RESTAPI\Core\Model;
use RESTAPI\Fields\BooleanField;
use RESTAPI\Fields\ForeignModelField;
use RESTAPI\Fields\ObjectField;
use RESTAPI\Fields\StringField;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ namespace RESTAPI\Models;
use RESTAPI\Core\Model;
use RESTAPI\Fields\BooleanField;
use RESTAPI\Fields\IntegerField;
use RESTAPI\Fields\NestedModelField;
use RESTAPI\Fields\PortField;
use RESTAPI\Fields\StringField;

/**
Expand Down
Loading