Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 .github/workflows/container-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ jobs:
- name: simplerisk (full-stack) generator
run: ./simplerisk/test_generate_dockerfile.sh

- name: simplerisk-minimal *_FILE secrets support
run: ./simplerisk-minimal/test_file_env.sh

- name: simplerisk-minimal credential escaping
run: ./simplerisk-minimal/test_escaping.sh

- name: simplerisk-minimal Apache worker setting
run: ./simplerisk-minimal/test_set_apache_workers.sh

Expand Down
27 changes: 27 additions & 0 deletions simplerisk-minimal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,33 @@ docker run -d --name simplerisk -e SIMPLERISK_DB_PASSWORD=pass -e SIMPLERISK_DB_
| `ADMIN_EMAIL` | — | Email address for the initial admin user |
| `ADMIN_NAME` | `Administrator` | Display name for the initial admin user |

## Secrets (`_FILE` variables)

To keep credentials out of the container environment (and out of compose files), the following variables can instead be supplied as a path to a file, by appending `_FILE` to the name. This works with Docker secrets (`/run/secrets/<name>`), Kubernetes secret mounts, or any mounted file. The entrypoint reads the file, trailing newlines are stripped, and the `_FILE` variable is removed before Apache starts.

`DB_SETUP_USER`, `DB_SETUP_PASS`, `SIMPLERISK_DB_HOSTNAME`, `SIMPLERISK_DB_PORT`, `SIMPLERISK_DB_USERNAME`, `SIMPLERISK_DB_PASSWORD`, `SIMPLERISK_DB_DATABASE`, `SIMPLERISK_CSRF_SECRET`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `ADMIN_USERNAME`, `ADMIN_PASSWORD`

Setting both `VAR` and `VAR_FILE` is an error, as is a `_FILE` path that is not a readable file; the container exits with a message.

```yaml
services:
simplerisk:
image: simplerisk/simplerisk-minimal
environment:
- DB_SETUP=automatic
- DB_SETUP_PASS_FILE=/run/secrets/dbms_root_pass
- SIMPLERISK_DB_PASSWORD_FILE=/run/secrets/db_pass
secrets:
- dbms_root_pass
- db_pass

secrets:
dbms_root_pass:
file: ./dbms_root_pass.txt
db_pass:
file: ./db_pass.txt
```

## Mail settings

The following environment variables configure the outbound mail settings stored in the SimpleRisk database (`settings` table). They are applied on every container start, so changing a value and restarting the container is all that is needed to update the configuration. Invalid values are silently skipped with a warning in the container logs.
Expand Down
159 changes: 123 additions & 36 deletions simplerisk-minimal/common/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,109 @@ generate_random_password() {
}

# Escape a value for use as the replacement in a sed s|...|VALUE|... command.
# Escapes \, &, and | so they aren't interpreted as sed metacharacters.
# Required for any env-var-derived value that may contain / (which would
# otherwise terminate the / delimiter the sed substitution used to use).
# Escapes \, &, | and newlines so they aren't interpreted as sed metacharacters
# or terminate the s command. Required for any env-var-derived value that may
# contain / (which would otherwise terminate the / delimiter the sed
# substitution used to use). Done with parameter expansion (not a sed pipe) so
# embedded newlines survive.
sed_escape() {
printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'
local v="$1"
v="${v//\\/\\\\}"
v="${v//&/\\&}"
v="${v//|/\\|}"
v="${v//$'\n'/\\$'\n'}"
printf '%s' "$v"
}

# Escape a value for a PHP single-quoted string literal ('...'): only \ and '
# are special there, so a value can neither close the string nor inject code.
php_sq_escape() {
local v="$1"
v="${v//\\/\\\\}"
v="${v//\'/\\\'}"
printf '%s' "$v"
}

# Escape a value for a MySQL single-quoted string literal ('...').
sql_str_escape() {
local v="$1"
v="${v//\\/\\\\}"
v="${v//\'/\\\'}"
printf '%s' "$v"
}

# Escape a value for a MySQL ANSI_QUOTES identifier ("...").
sql_ident_escape() {
local v="$1"
printf '%s' "${v//\"/\"\"}"
}

# Rewrite define('NAME', '...'); in $CONFIG_PATH. The value is PHP-escaped first
# (so it cannot break out of the string in config.php) and then sed-escaped.
config_define() {
local name="$1" value="$2"
sed -i "s|\('${name}', '\).*\(');\)|\1$(sed_escape "$(php_sq_escape "$value")")\2|g" "$CONFIG_PATH"
}

# Run the mysql client as the privileged setup user, reading SQL from stdin.
# Arguments are passed as an argv array (never through a shell string), and the
# password via MYSQL_PWD, so no value is subject to shell interpretation.
mysql_setup() {
local -a flags=()
# DB_SSL_FLAGS is an internal constant ("--ssl-mode=... --enable-cleartext-plugin")
[ -n "${DB_SSL_FLAGS:-}" ] && read -ra flags <<< "$DB_SSL_FLAGS"
MYSQL_PWD="$DB_SETUP_PASS" mysql ${flags[@]+"${flags[@]}"} \
-u "$DB_SETUP_USER" -h "$SIMPLERISK_DB_HOSTNAME" -P "$SIMPLERISK_DB_PORT"
}

fatal_error(){
print_log "error" "$1"
exit 1
}

# Docker/Kubernetes secrets support: if VAR_FILE is set, read VAR from the file it
# points to (typically /run/secrets/<name>), so credentials need not live in the
# container's environment. Setting both VAR and VAR_FILE is an error. The _FILE
# variable is unset afterwards so it is not inherited by child processes.
file_env() {
local var="$1"
local file_var="${var}_FILE"
local var_val="${!var:-}"
local file_val="${!file_var:-}"

if [ -n "$var_val" ] && [ -n "$file_val" ]; then
fatal_error "Both $var and $file_var are set (but are exclusive)."
fi

if [ -n "$file_val" ]; then
[ -r "$file_val" ] || fatal_error "$file_var points to '$file_val', which is not a readable file."
# $(< file) strips trailing newlines, which secret files commonly end with
export "$var=$(< "$file_val")"
fi
unset "$file_var"
}

load_file_envs() {
local var
for var in \
DB_SETUP_USER DB_SETUP_PASS \
SIMPLERISK_DB_HOSTNAME SIMPLERISK_DB_PORT SIMPLERISK_DB_USERNAME \
SIMPLERISK_DB_PASSWORD SIMPLERISK_DB_DATABASE \
SIMPLERISK_CSRF_SECRET \
MAIL_USERNAME MAIL_PASSWORD \
ADMIN_USERNAME ADMIN_PASSWORD; do
file_env "$var"
done
}

set_db_password(){
if [[ "${DB_SETUP:-}" = automatic* ]]; then
# shellcheck disable=SC2015
[ -z "${SIMPLERISK_DB_PASSWORD:-}" ] && SIMPLERISK_DB_PASSWORD=$(generate_random_password) && print_log "initial_setup:warn" "As no password was provided and this is a first time setup, a random password has been generated ($SIMPLERISK_DB_PASSWORD)"
else
SIMPLERISK_DB_PASSWORD=${SIMPLERISK_DB_PASSWORD:-simplerisk}
fi
sed -i "s|\('DB_PASSWORD', '\).*\(');\)|\1$(sed_escape "$SIMPLERISK_DB_PASSWORD")\2|g" "$CONFIG_PATH"
config_define DB_PASSWORD "$SIMPLERISK_DB_PASSWORD"
}

validate_db_setup(){
Expand Down Expand Up @@ -93,28 +176,28 @@ set_config(){
# Replacing config variables. Values are run through sed_escape so paths
# or other inputs containing \, &, or | don't break the substitution.
SIMPLERISK_DB_HOSTNAME=${SIMPLERISK_DB_HOSTNAME:-localhost}
sed -i "s|\('DB_HOSTNAME', '\).*\(');\)|\1$(sed_escape "$SIMPLERISK_DB_HOSTNAME")\2|g" "$CONFIG_PATH"
config_define DB_HOSTNAME "$SIMPLERISK_DB_HOSTNAME"

SIMPLERISK_DB_PORT=${SIMPLERISK_DB_PORT:-3306}
sed -i "s|\('DB_PORT', '\).*\(');\)|\1$(sed_escape "$SIMPLERISK_DB_PORT")\2|g" "$CONFIG_PATH"
config_define DB_PORT "$SIMPLERISK_DB_PORT"

SIMPLERISK_DB_USERNAME=${SIMPLERISK_DB_USERNAME:-simplerisk}
sed -i "s|\('DB_USERNAME', '\).*\(');\)|\1$(sed_escape "$SIMPLERISK_DB_USERNAME")\2|g" "$CONFIG_PATH"
config_define DB_USERNAME "$SIMPLERISK_DB_USERNAME"

set_db_password

SIMPLERISK_DB_DATABASE=${SIMPLERISK_DB_DATABASE:-simplerisk}
sed -i "s|\('DB_DATABASE', '\).*\(');\)|\1$(sed_escape "$SIMPLERISK_DB_DATABASE")\2|g" "$CONFIG_PATH"
config_define DB_DATABASE "$SIMPLERISK_DB_DATABASE"

SIMPLERISK_DB_FOR_SESSIONS=${SIMPLERISK_DB_FOR_SESSIONS:-true}
sed -i "s|\('USE_DATABASE_FOR_SESSIONS', '\).*\(');\)|\1$(sed_escape "$SIMPLERISK_DB_FOR_SESSIONS")\2|g" "$CONFIG_PATH"
config_define USE_DATABASE_FOR_SESSIONS "$SIMPLERISK_DB_FOR_SESSIONS"

if [ -n "${SIMPLERISK_DB_SSL_CERT_PATH:-}" ]; then
# The sample ships DB_SSL_CERTIFICATE_PATH as a commented-out line so
# that operators who don't set the env var don't end up requesting SSL
# against a non-existent cert path. When a path is supplied, rewrite
# the whole line (commented or not) as an active define.
escaped_ssl_path=$(sed_escape "$SIMPLERISK_DB_SSL_CERT_PATH")
escaped_ssl_path=$(sed_escape "$(php_sq_escape "$SIMPLERISK_DB_SSL_CERT_PATH")")
sed -i "s|^[[:space:]]*\(//[[:space:]]*\)\{0,1\}define('DB_SSL_CERTIFICATE_PATH', '[^']*');|define('DB_SSL_CERTIFICATE_PATH', '${escaped_ssl_path}');|" "$CONFIG_PATH"
fi

Expand Down Expand Up @@ -199,7 +282,9 @@ set_csrf_secret(){
CSRF_SECRET_PATH='/var/www/simplerisk/vendor/simplerisk/csrf-magic/csrf-secret.php'

# If a SIMPLERISK_CSRF_SECRET value was specified create the csrf-secret.php file with that value
[ -n "${SIMPLERISK_CSRF_SECRET:-}" ] && echo "<?php \$secret = \"${SIMPLERISK_CSRF_SECRET}\"; ?>" > "$CSRF_SECRET_PATH";
# (single-quoted PHP string, escaped, so the value cannot inject code or be interpolated)
# shellcheck disable=SC2015
[ -n "${SIMPLERISK_CSRF_SECRET:-}" ] && printf "<?php \$secret = '%s'; ?>\n" "$(php_sq_escape "$SIMPLERISK_CSRF_SECRET")" > "$CSRF_SECRET_PATH" || true
}

set_ssl_certificate(){
Expand Down Expand Up @@ -297,14 +382,15 @@ set_cron(){

apply_mail_setting(){
local db_key="$1" value="$2"
# Escape backslashes then single quotes for a MySQL single-quoted string literal
local escaped
escaped=$(printf '%s' "$value" | sed 's/\\/\\\\/g' | sed "s/'/\\\\'/g")
mysql -u "$SIMPLERISK_DB_USERNAME" \
-p"$SIMPLERISK_DB_PASSWORD" \
escaped=$(sql_str_escape "$value")
# No --ssl-mode override: the client's default (PREFERRED) negotiates TLS when the
# server offers it. Forcing DISABLED made MySQL 8's caching_sha2_password refuse the
# first login ("Authentication requires secure connection"), silently skipping
# every mail setting that needs the app user's password.
MYSQL_PWD="$SIMPLERISK_DB_PASSWORD" mysql -u "$SIMPLERISK_DB_USERNAME" \
-h "$SIMPLERISK_DB_HOSTNAME" \
-P "$SIMPLERISK_DB_PORT" \
--ssl-mode=DISABLED \
"$SIMPLERISK_DB_DATABASE" \
-e "UPDATE settings SET value='${escaped}' WHERE name='${db_key}';" \
|| print_log "mail_settings:warn" "Failed to update ${db_key}"
Expand Down Expand Up @@ -402,16 +488,13 @@ delete_db(){
print_log "db_deletion: prepare" "Performing database deletion"

# Pass password via env var to avoid shell interpretation of special characters in the value
export MYSQL_PWD="$DB_SETUP_PASS"
# Needed to separate the GRANT statement from the rest because it was providing a syntax error
exec_cmd "mysql ${DB_SSL_FLAGS:-} -u $DB_SETUP_USER -h$SIMPLERISK_DB_HOSTNAME -P$SIMPLERISK_DB_PORT <<EOSQL
mysql_setup <<EOSQL || fatal_error "Was not able to apply settings on database. Check error above. Exiting."
SET sql_mode = 'ANSI_QUOTES';
DROP DATABASE \"${SIMPLERISK_DB_DATABASE}\";
DROP DATABASE "$(sql_ident_escape "$SIMPLERISK_DB_DATABASE")";
USE mysql;
DROP USER '${SIMPLERISK_DB_USERNAME}'@'%';
DROP USER '$(sql_str_escape "$SIMPLERISK_DB_USERNAME")'@'%';
FLUSH PRIVILEGES;
EOSQL" "Was not able to apply settings on database. Check error above. Exiting."
unset MYSQL_PWD
EOSQL

print_log "db_deletion: done" "Database deletion performed. Exiting."
exit 0
Expand All @@ -434,22 +517,24 @@ db_setup(){
fi

print_log "initial_setup:info" "Applying changes to MySQL database... (MySQL error will be printed to console as guidance)"
# Pass password via env var to avoid shell interpretation of special characters in the value
export MYSQL_PWD="$DB_SETUP_PASS"
# Using sql_mode = ANSI_QUOTES to avoid using backticks
exec_cmd "mysql ${DB_SSL_FLAGS:-} -u $DB_SETUP_USER -h$SIMPLERISK_DB_HOSTNAME -P$SIMPLERISK_DB_PORT <<EOSQL
# Using sql_mode = ANSI_QUOTES for identifiers; string values are single-quoted
# and escaped, so no value is interpreted by a shell or by the SQL parser.
local db_ident user_str pass_str
db_ident=$(sql_ident_escape "$SIMPLERISK_DB_DATABASE")
user_str=$(sql_str_escape "$SIMPLERISK_DB_USERNAME")
pass_str=$(sql_str_escape "$SIMPLERISK_DB_PASSWORD")
mysql_setup <<EOSQL || fatal_error "Was not able to apply settings on database. Check error above. Exiting."
SET sql_mode = 'ANSI_QUOTES';
CREATE DATABASE \"${SIMPLERISK_DB_DATABASE}\";
USE \"${SIMPLERISK_DB_DATABASE}\";
CREATE DATABASE "${db_ident}";
USE "${db_ident}";
\. ${SCHEMA_FILE}
CREATE USER \"${SIMPLERISK_DB_USERNAME}\"@\"%\" IDENTIFIED BY \"${SIMPLERISK_DB_PASSWORD}\";
EOSQL" "Was not able to apply settings on database. Check error above. Exiting."
CREATE USER '${user_str}'@'%' IDENTIFIED BY '${pass_str}';
EOSQL
# Needed to separate the GRANT statement from the rest because it was providing a syntax error
exec_cmd "mysql ${DB_SSL_FLAGS:-} -u $DB_SETUP_USER -h$SIMPLERISK_DB_HOSTNAME -P$SIMPLERISK_DB_PORT <<EOSQL
mysql_setup <<EOSQL || fatal_error "Was not able to apply settings on database. Check error above. Exiting."
SET sql_mode = 'ANSI_QUOTES';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, REFERENCES, INDEX, ALTER ON \"${SIMPLERISK_DB_DATABASE}\".* TO \"${SIMPLERISK_DB_USERNAME}\"@\"%\";
EOSQL" "Was not able to apply settings on database. Check error above. Exiting."
unset MYSQL_PWD
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, REFERENCES, INDEX, ALTER ON "${db_ident}".* TO '${user_str}'@'%';
EOSQL

print_log "initial_setup:info" "Setup has been applied successfully!"
print_log "initial_setup:info" "Removing schema file..."
Expand Down Expand Up @@ -520,6 +605,8 @@ unset_variables() {
}

_main() {
load_file_envs

set_ssl_certificate

# Detect whether the operator has opted into Docker-managed config
Expand Down
57 changes: 57 additions & 0 deletions simplerisk-minimal/test_escaping.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Regression checks that credential values containing shell/PHP/SQL/sed
# metacharacters are neutralised by the escaping helpers in common/entrypoint.sh.
# Requires bash >= 5 and GNU sed (as in the image and ubuntu CI runners); macOS
# /bin/bash 3.2 and BSD sed give false failures, so run it inside the image there.
set -uo pipefail
cd "$(dirname "$(readlink -f "$0")")" || exit 1

fail=0
ok() { echo "ok: $1"; }
bad() { echo "FAIL: $1"; fail=1; }

work=$(mktemp -d); trap 'rm -rf "$work"' EXIT

# Pull in only the helpers under test.
helpers=$(sed -n '/^sed_escape() {/,/^}/p;/^php_sq_escape() {/,/^}/p;/^sql_str_escape() {/,/^}/p;/^sql_ident_escape() {/,/^}/p;/^config_define() {/,/^}/p;/^set_csrf_secret(){/,/^}/p' common/entrypoint.sh \
| sed "s|/var/www/simplerisk/vendor/simplerisk/csrf-magic/csrf-secret.php|$work/csrf-secret.php|")
eval "$helpers"

# A value hitting every metacharacter class: " ' \ $ ` & | newline and $(cmd).
nasty=$'a"b\'c\\d$(touch '"$work"$'/PWNED)`touch '"$work"$'/PWNED2`&e|f\ng'

# --- sed_escape: round-trips through a real sed substitution, newline included
printf 'X\n' > "$work/in"
sed -i "s|X|$(sed_escape "$nasty")|" "$work/in"
[ "$(cat "$work/in")" = "$nasty" ] && ok "sed_escape round-trips nasty value" || bad "sed_escape round-trip"

# --- SQL escapers
[ "$(sql_str_escape "a'b\\c")" = "a\\'b\\\\c" ] && ok "sql_str_escape" || bad "sql_str_escape"
[ "$(sql_ident_escape 'a"b')" = 'a""b' ] && ok "sql_ident_escape" || bad "sql_ident_escape"

# --- PHP: config.php define and csrf-secret.php must evaluate back to the exact value
if command -v php >/dev/null 2>&1; then
CONFIG_PATH="$work/config.php"
printf "<?php\ndefine('DB_PASSWORD', 'x');\n" > "$CONFIG_PATH"
config_define DB_PASSWORD "$nasty"
got=$(php -r "include '$CONFIG_PATH'; echo DB_PASSWORD;")
[ "$got" = "$nasty" ] && ok "config_define yields exact value in PHP" || bad "config_define (got: $got)"

SIMPLERISK_CSRF_SECRET="$nasty" set_csrf_secret
got=$(php -r "include '$work/csrf-secret.php'; echo \$secret;")
[ "$got" = "$nasty" ] && ok "set_csrf_secret yields exact value in PHP" || bad "set_csrf_secret (got: $got)"
else
echo "skip: php not installed (PHP round-trip checks)"
fi

# --- apply_mail_setting must not force a non-TLS connection (breaks caching_sha2_password)
if sed -n '/^apply_mail_setting(){/,/^}/p' common/entrypoint.sh | grep -q -- '--ssl-mode=DISABLED'; then
bad "apply_mail_setting forces --ssl-mode=DISABLED"
else
ok "apply_mail_setting does not force --ssl-mode=DISABLED"
fi

# --- nothing in the nasty value may have been executed
[ ! -e "$work/PWNED" ] && [ ! -e "$work/PWNED2" ] && ok "no command substitution executed" || bad "injected command executed"

exit $fail
29 changes: 29 additions & 0 deletions simplerisk-minimal/test_file_env.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Regression checks for the *_FILE secrets support in common/entrypoint.sh.
set -uo pipefail
cd "$(dirname "$(readlink -f "$0")")" || exit 1

fail=0
ok() { echo "ok: $1"; }
bad() { echo "FAIL: $1"; fail=1; }

# Load only the helpers under test; fatal_error is stubbed to exit the subshell.
helpers=$(sed -n '/^file_env() {/,/^}/p;/^load_file_envs() {/,/^}/p' common/entrypoint.sh)
run() { bash -c 'set -eo pipefail; fatal_error(){ echo "FATAL: $1"; exit 1; }; '"$helpers"'; '"$1"; }

dir=$(mktemp -d); trap 'rm -rf "$dir"' EXIT
printf 'p@ss w/&|\n' > "$dir/secret"

out=$(DB_SETUP_PASS_FILE="$dir/secret" run 'load_file_envs; [ "$DB_SETUP_PASS" = "p@ss w/&|" ] && [ -z "${DB_SETUP_PASS_FILE:-}" ] && echo pass')
[ "$out" = pass ] && ok "reads file, strips newline, unsets _FILE" || bad "reads file (got: $out)"

SIMPLERISK_DB_PASSWORD=x SIMPLERISK_DB_PASSWORD_FILE="$dir/secret" run 'load_file_envs' >/dev/null \
&& bad "VAR and VAR_FILE together should be rejected" || ok "VAR and VAR_FILE together rejected"

MAIL_PASSWORD_FILE="$dir/missing" run 'load_file_envs' >/dev/null \
&& bad "unreadable _FILE should be rejected" || ok "unreadable _FILE rejected"

SIMPLERISK_DB_PASSWORD=keep run 'load_file_envs; [ "$SIMPLERISK_DB_PASSWORD" = keep ]' \
&& ok "plain VAR untouched when no _FILE" || bad "plain VAR changed"

exit $fail
Loading