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
10 changes: 8 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@ MYSQL_PASSWORD=
# Required only for the first database initialization; at least 16 characters.
DEFAULT_ADMIN_PASSWORD=

# Required application secret. Generate it with: openssl rand -hex 32
# Required application secret: exactly 64 hexadecimal characters (32 bytes).
# New installs: generate it with: openssl rand -hex 32
# Upgrades: do NOT generate a new value. Copy the unchanged APP_ENCRYPTION_KEY from your old .env.
# Never rotate this value blindly; it is persistent key material.
APP_SECRET=

# Required canonical public URL (absolute http/https; no credentials, query, or fragment).
# Required canonical browser-facing URL, including the scheme (e.g. https://portal.example.test).
# Behind a TLS reverse proxy, use the public HTTPS address, not the internal container host or port.
# An optional public port or base path is allowed. No credentials, query, fragment, or whitespace.
# Upgrades from older templates may lack this line; add it explicitly.
APP_URL=

# OpenAI API Configuration
Expand Down
60 changes: 50 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,16 @@ A flexible post and update platform built on PHP with Smarty templates, featurin
# Required only for the first database initialization; at least 16 characters.
DEFAULT_ADMIN_PASSWORD=

# Required application secret. Generate it with: openssl rand -hex 32
# Required application secret: exactly 64 hexadecimal characters (32 bytes).
# New installs: generate it with: openssl rand -hex 32
# Upgrades: do NOT generate a new value. Copy the unchanged APP_ENCRYPTION_KEY from your old .env.
# Never rotate this value blindly; it is persistent key material.
APP_SECRET=

# Required canonical public URL (absolute http/https; no credentials, query, or fragment).
# Required canonical browser-facing URL, including the scheme (e.g. https://portal.example.test).
# Behind a TLS reverse proxy, use the public HTTPS address, not the internal container host or port.
# An optional public port or base path is allowed. No credentials, query, fragment, or whitespace.
# Upgrades from older templates may lack this line; add it explicitly.
APP_URL=

# OpenAI API Configuration
Expand Down Expand Up @@ -80,7 +86,7 @@ A flexible post and update platform built on PHP with Smarty templates, featurin

</details>

2. **Edit `.env`** and configure your specific values (see [Environment Variables](#environment-variables) section)
2. **Edit `.env`** and configure your specific values (see [Environment Variables](#environment-variables) section). `APP_SECRET` and `APP_URL` are required; see [Application Secret Setup](#application-secret-setup) and [Public URL (APP_URL)](#public-url-app_url).

3. **Create docker-compose.yml:**

Expand Down Expand Up @@ -151,23 +157,57 @@ Database imports require a MariaDB client with sandbox support and disable local

To update to the latest version:

Complete the [backup](#backup--restore) and [upgrade `.env` checks](#upgrading-an-existing-env) before running these commands.

```bash
docker compose pull
docker compose up -d
docker compose up -d postportal
```
> 💡 **Tip:** [Create a backup](#gui-backup--restore) first.

If MariaDB system tables exist but Post Portal initialization is incomplete, startup fails closed. Use a new empty volume, restoring a backup into it when existing data must be recovered, rather than expecting automatic repair.

### Application Secret Setup

- **New installations:** Generate `APP_SECRET` with `openssl rand -hex 32`.
- **Upgrades from `APP_ENCRYPTION_KEY`:** Do **not** generate a new value. Copy the unchanged old `APP_ENCRYPTION_KEY` value to `APP_SECRET` so startup can migrate v2 SMTP ciphertext.
`APP_SECRET` must be exactly 64 hexadecimal characters (32 bytes). Startup rejects any other length or non-hexadecimal character.

- **New installations:** Generate it with `openssl rand -hex 32`.
- **Upgrades from `APP_ENCRYPTION_KEY`:** Do **not** generate a new value. Copy the unchanged old `APP_ENCRYPTION_KEY` value to `APP_SECRET` so startup can migrate v2 SMTP ciphertext. Generating a new value cannot decrypt existing ciphertext.

After the first hardened start, treat `APP_SECRET` as persistent key material; do not change it without a corresponding encrypted-data migration.
After the first hardened start, treat `APP_SECRET` as persistent key material. Never rotate it blindly; do not change it without a corresponding encrypted-data migration.

Existing unsubscribe links become invalid after this upgrade.

### Upgrading an Existing `.env`

Older `.env` files may lack `APP_URL`, and may hold `APP_ENCRYPTION_KEY` instead of `APP_SECRET`. Startup rejects the configuration until both are correct.

1. **Back up persistent data before the first upgraded start.** Startup runs database migrations.
- Keep a private copy of the current `.env` in your secure backup location before you edit it. Database and media backups do not contain `APP_SECRET`, `APP_ENCRYPTION_KEY`, or database credentials, so without this copy the encrypted data may be unrecoverable.
- If the existing site is still reachable, use **Admin → Backup** to download the database and media.
- Otherwise, stop the service first so MariaDB is not writing to the volume: `docker compose stop postportal`. Then copy the `db_data` volume and the `./storage` and `./logs` directories with your usual method.
2. **Edit the existing `.env` in place.** Do not replace it with `.env.example`, which would overwrite your database settings. Keep `MYSQL_USER` and `MYSQL_PASSWORD` unchanged (see [Upgrading Existing Database Credentials](#upgrading-existing-database-credentials)).
3. **Set `APP_SECRET`.**
- If the old file has `APP_ENCRYPTION_KEY`, copy its value unchanged to `APP_SECRET`. Keep the old line until the upgraded start succeeds.
- If `APP_SECRET` is already present and is 64 hexadecimal characters, leave it unchanged.
- If neither value exists, stop and confirm the correct key with the person who manages the deployment. Do not generate a new value for an existing installation.
4. **Add `APP_URL`** as described in [Public URL (APP_URL)](#public-url-app_url).
5. **Recreate the service:**
```bash
docker compose up -d postportal
```
`docker compose restart` does not reload `.env`, so the container keeps its previous environment. Use `up -d` after every `.env` change.
6. **Check the container logs** with `docker compose logs postportal`. Startup validates the resolved environment. A message beginning with `Invalid runtime configuration:` names the setting to fix; correct `.env` and repeat step 5.

### Public URL (APP_URL)

`APP_URL` is required on every install, including upgrades. Set it to the canonical address visitors use in the browser.

- Include the scheme. Use `https` for public sites: `APP_URL=https://portal.example.test`.
- Behind a TLS reverse proxy, use the public HTTPS address, not the internal container hostname or HTTP port.
- An optional public port (`https://portal.example.test:8443`) or base path (`https://portal.example.test/portal`) is allowed when the public address uses one.
- Do not include credentials (`user:password@`), a query string (`?`), a fragment (`#`), or whitespace or control characters.
- Keep the value consistent with how the site is served. Start-up validation checks format only; it does not confirm that the URL reaches the site.

### Upgrading Existing Database Credentials

When upgrading a database volume created before password-policy enforcement, keep its existing `MYSQL_USER` and `MYSQL_PASSWORD` values for the first upgraded start. Existing non-empty credentials are accepted for compatibility only when they authenticate the persisted database; new databases still require passwords of at least 16 characters.
Expand Down Expand Up @@ -195,8 +235,8 @@ Note: MySQL root password is auto-generated for security.
- `DEFAULT_ADMIN_PASSWORD` - Initial admin user password (required for first setup)

### Application Configuration
- `APP_SECRET` - Required 64-character hexadecimal application secret. New installations generate it with `openssl rand -hex 32`; upgrades retain the unchanged old `APP_ENCRYPTION_KEY` value instead.
- `APP_URL` - Required canonical public URL. It cannot be safely derived server-side because reverse proxies can obscure the original public scheme and host.
- `APP_SECRET` - Required application secret: exactly 64 hexadecimal characters. New installations generate it with `openssl rand -hex 32`; upgrades reuse the unchanged old `APP_ENCRYPTION_KEY` value. See [Application Secret Setup](#application-secret-setup).
- `APP_URL` - Required canonical browser-facing URL with scheme, e.g. `https://portal.example.test`. It cannot be safely derived server-side because reverse proxies can obscure the original public scheme and host. See [Public URL (APP_URL)](#public-url-app_url).

### Debug Settings
- `DEBUG` - Enable debug mode (default: `false`)
Expand Down
45 changes: 33 additions & 12 deletions docker/scripts/validate-environment.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,42 @@ LC_ALL=C

fail() {
printf 'Invalid runtime configuration: %s\n' "$1" >&2
[ -z "${2:-}" ] || printf '%s\n' "$2" >&2
exit 1
}

app_secret_remediation='APP_SECRET must be exactly 64 hexadecimal characters (32 bytes).
For new installs only, generate one with: openssl rand -hex 32
For upgrades, reuse the existing APP_ENCRYPTION_KEY unchanged as APP_SECRET.
Do not rotate existing key material blindly.
After editing .env, recreate the service with: docker compose up -d postportal (restart does not apply changed environment).'
app_url_remediation='Set, add, or correct APP_URL in .env as the canonical browser-facing public http/https URL, for example: https://portal.example.test.
Use a plain hostname or IP address with an optional port.
For TLS terminated by a reverse proxy, use the browser-facing public HTTPS URL, not the internal container address.
APP_URL must not contain credentials, query strings, fragments, or whitespace.
After editing .env, recreate the service with: docker compose up -d postportal (restart does not apply changed environment).'

required_remediation() {
case "$1" in
MYSQL_DATABASE|MYSQL_USER|MYSQL_PASSWORD) printf 'Set %s to a non-empty value in .env. For an existing database, restore its original %s value.' "$1" "$1" ;;
DEFAULT_ADMIN_PASSWORD) printf 'Set DEFAULT_ADMIN_PASSWORD to a non-empty value in .env. Existing databases keep the preserved administrator account; no reset is needed.' ;;
APP_SECRET) printf '%s' "$app_secret_remediation" ;;
APP_URL) printf '%s' "$app_url_remediation" ;;
esac
}

required=(MYSQL_DATABASE MYSQL_USER MYSQL_PASSWORD DEFAULT_ADMIN_PASSWORD APP_SECRET APP_URL)
for name in "${required[@]}"; do
[ -n "${!name:-}" ] || fail "${name} is required"
[ -n "${!name:-}" ] || fail "${name} is required" "$(required_remediation "$name")"
done

case "${POSTPORTAL_DATABASE_FRESH:-}" in
true|false) ;;
*) fail 'POSTPORTAL_DATABASE_FRESH is invalid' ;;
*) fail 'POSTPORTAL_DATABASE_FRESH is invalid' 'POSTPORTAL_DATABASE_FRESH is set internally by the entrypoint; do not add or edit it in .env.' ;;
esac

for name in MYSQL_DATABASE MYSQL_USER; do
[[ "${!name}" =~ ^[A-Za-z_][A-Za-z0-9_]{0,63}$ ]] || fail "${name} is invalid"
[[ "${!name}" =~ ^[A-Za-z_][A-Za-z0-9_]{0,63}$ ]] || fail "${name} is invalid" 'Use a MySQL identifier beginning with a letter or underscore, followed by up to 63 letters, digits, or underscores.'
done

is_placeholder() {
Expand All @@ -36,27 +57,27 @@ for name in MYSQL_PASSWORD DEFAULT_ADMIN_PASSWORD; do
value="${!name}"
if ! meets_password_policy "$value"; then
if [ "$POSTPORTAL_DATABASE_FRESH" = true ]; then
fail "${name} does not meet password policy"
fail "${name} does not meet password policy" 'For a new database, use at least 16 characters and not a placeholder value.'
fi
printf 'WARNING: %s does not meet current password policy; continuing for existing database\n' "$name" >&2
fi
done

[[ "$APP_SECRET" =~ ^[[:xdigit:]]{64}$ ]] || fail 'APP_SECRET is invalid'
[[ "$APP_SECRET" =~ ^[[:xdigit:]]{64}$ ]] || fail 'APP_SECRET is invalid' "$app_secret_remediation"

[[ "$APP_URL" =~ ^https?:// ]] || fail 'APP_URL is invalid'
[[ "$APP_URL" != *$'\n'* && "$APP_URL" != *$'\r'* && "$APP_URL" != *$'\t'* && "$APP_URL" != *'?'* && "$APP_URL" != *'#'* && "$APP_URL" != *' '* ]] || fail 'APP_URL is invalid'
[[ "$APP_URL" =~ ^https?:// ]] || fail 'APP_URL is invalid' "$app_url_remediation"
[[ "$APP_URL" != *$'\n'* && "$APP_URL" != *$'\r'* && "$APP_URL" != *$'\t'* && "$APP_URL" != *'?'* && "$APP_URL" != *'#'* && "$APP_URL" != *' '* ]] || fail 'APP_URL is invalid' "$app_url_remediation"
app_authority="${APP_URL#*://}"
app_authority="${app_authority%%/*}"
[[ "$app_authority" != *'@'* && -n "$app_authority" ]] || fail 'APP_URL is invalid'
[[ "$app_authority" =~ ^[A-Za-z0-9.-]+(:[0-9]{1,5})?$ || "$app_authority" =~ ^\[[0-9A-Fa-f:]+\](:[0-9]{1,5})?$ ]] || fail 'APP_URL is invalid'
[[ "$app_authority" != *'@'* && -n "$app_authority" ]] || fail 'APP_URL is invalid' "$app_url_remediation"
[[ "$app_authority" =~ ^[A-Za-z0-9.-]+(:[0-9]{1,5})?$ || "$app_authority" =~ ^\[[0-9A-Fa-f:]+\](:[0-9]{1,5})?$ ]] || fail 'APP_URL is invalid' "$app_url_remediation"

bind_address="${MYSQL_BIND_ADDRESS:-127.0.0.1}"
[[ "$bind_address" == '127.0.0.1' || "$bind_address" == '0.0.0.0' ]] || fail 'MYSQL_BIND_ADDRESS is invalid'
[[ "$bind_address" == '127.0.0.1' || "$bind_address" == '0.0.0.0' ]] || fail 'MYSQL_BIND_ADDRESS is invalid' 'Use 127.0.0.1 for local-only MySQL access or 0.0.0.0 only when external access is required.'

for name in PHP_UPLOAD_MAX_FILESIZE PHP_POST_MAX_SIZE PHP_MEMORY_LIMIT; do
value="${!name:-}"
[ -z "$value" ] || [[ "$value" =~ ^[1-9][0-9]*[KMG]?$ ]] || fail "${name} is invalid"
[ -z "$value" ] || [[ "$value" =~ ^[1-9][0-9]*[KMG]?$ ]] || fail "${name} is invalid" 'Use a positive integer with an optional K, M, or G suffix.'
done
value="${PHP_MAX_EXECUTION_TIME:-}"
[ -z "$value" ] || [[ "$value" =~ ^[1-9][0-9]*$ ]] || fail 'PHP_MAX_EXECUTION_TIME is invalid'
[ -z "$value" ] || [[ "$value" =~ ^[1-9][0-9]*$ ]] || fail 'PHP_MAX_EXECUTION_TIME is invalid' 'Use a positive whole number of seconds.'
100 changes: 94 additions & 6 deletions tests/Security/Runtime/startup-security-test.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail

# Production change this catches: startup accepts weak/malformed configuration
# or emits the supplied secret while rejecting it.
# Production change this catches: startup accepts weak/malformed configuration,
# emits supplied values while rejecting it, or removes safe recovery guidance.
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
validator="$repo_root/docker/scripts/validate-environment.sh"
secret='Punct!$`;&\\ quote" value-0123456789'
Expand Down Expand Up @@ -36,15 +36,103 @@ assert_rejected_without_secret() {
[[ "$output" != *"$secret"* ]] || fail "validator disclosed a supplied secret"
}

capture_rejection() {
local status
set +e
validator_output="$(run_validator "$@" 2>&1)"
status=$?
set -e
[ "$status" -ne 0 ] || fail 'invalid configuration was accepted'
}

assert_output_has_tokens() {
local expected
for expected in "$@"; do
[[ "$validator_output" == *"$expected"* ]] \
|| fail "missing required diagnostic token: $expected"
done
}

assert_output_hides() {
local forbidden
for forbidden in "$@"; do
[[ "$validator_output" != *"$forbidden"* ]] \
|| fail 'validator disclosed a supplied configuration value'
done
}

[ -x "$validator" ] || fail 'environment validator is missing or not executable'

run_validator >/dev/null || fail 'strong punctuation-rich configuration was rejected'
run_validator APP_SECRET=ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789 >/dev/null \
|| fail 'uppercase 64-hex APP_SECRET was rejected'
run_validator APP_URL=http://localhost:8080/postportal >/dev/null \
|| fail 'valid local APP_URL was rejected'
assert_rejected_without_secret 'Invalid runtime configuration: MYSQL_PASSWORD is required' MYSQL_PASSWORD=
assert_rejected_without_secret 'Invalid runtime configuration: DEFAULT_ADMIN_PASSWORD does not meet password policy' DEFAULT_ADMIN_PASSWORD=changeme_admin
assert_rejected_without_secret 'Invalid runtime configuration: APP_SECRET is required' APP_SECRET=
assert_rejected_without_secret 'Invalid runtime configuration: APP_SECRET is invalid' APP_SECRET=not-a-secret
assert_rejected_without_secret 'Invalid runtime configuration: APP_URL is invalid' APP_URL='https://user:pass@portal.example.test/'
assert_rejected_without_secret 'Invalid runtime configuration: APP_URL is invalid' APP_URL='https://portal.example.test/?query=value'
capture_rejection APP_SECRET=
assert_output_has_tokens \
'Invalid runtime configuration: APP_SECRET is required' \
'64 hexadecimal characters (32 bytes)' \
'openssl rand -hex 32' \
'new installs only' \
'reuse the existing APP_ENCRYPTION_KEY unchanged' \
'Do not rotate existing key material blindly'
assert_output_hides "$secret"
malformed_secret='not-a-secret'
capture_rejection APP_SECRET="$malformed_secret"
assert_output_has_tokens \
'Invalid runtime configuration: APP_SECRET is invalid' \
'64 hexadecimal characters (32 bytes)' \
'openssl rand -hex 32' \
'reuse the existing APP_ENCRYPTION_KEY unchanged' \
'Do not rotate existing key material blindly'
assert_output_hides "$secret" "$malformed_secret"
capture_rejection POSTPORTAL_DATABASE_FRESH=false MYSQL_DATABASE=
assert_output_has_tokens \
'Invalid runtime configuration: MYSQL_DATABASE is required' \
'restore its original MYSQL_DATABASE value'
capture_rejection POSTPORTAL_DATABASE_FRESH=false MYSQL_USER=
assert_output_has_tokens \
'Invalid runtime configuration: MYSQL_USER is required' \
'restore its original MYSQL_USER value'
capture_rejection POSTPORTAL_DATABASE_FRESH=false MYSQL_PASSWORD=
assert_output_has_tokens \
'Invalid runtime configuration: MYSQL_PASSWORD is required' \
'restore its original MYSQL_PASSWORD value'
capture_rejection POSTPORTAL_DATABASE_FRESH=false DEFAULT_ADMIN_PASSWORD=
assert_output_has_tokens \
'Invalid runtime configuration: DEFAULT_ADMIN_PASSWORD is required' \
'non-empty value' \
'preserved administrator account; no reset is needed'
capture_rejection POSTPORTAL_DATABASE_FRESH=maybe
assert_output_has_tokens \
'Invalid runtime configuration: POSTPORTAL_DATABASE_FRESH is invalid' \
'set internally by the entrypoint' \
'do not add or edit it in .env'
capture_rejection APP_URL=
assert_output_has_tokens \
'Invalid runtime configuration: APP_URL is required' \
'Set, add, or correct APP_URL in .env' \
'canonical browser-facing public http/https URL' \
'https://portal.example.test' \
'plain hostname or IP address with an optional port' \
'browser-facing public HTTPS URL, not the internal container address' \
'credentials, query strings, fragments, or whitespace' \
'docker compose up -d postportal' \
'restart does not apply changed environment'
assert_output_hides "$secret"
credential_userinfo='dbuser:dbpass@'
credential_password='dbpass'
credential_url="https://${credential_userinfo}portal.example.test/"
capture_rejection APP_URL="$credential_url"
assert_output_has_tokens 'Invalid runtime configuration: APP_URL is invalid' 'APP_URL must not contain credentials'
assert_output_hides "$secret" "$credential_url" "$credential_userinfo" "$credential_password"
query_value='private-query'
query_url="https://portal.example.test/?token=${query_value}"
capture_rejection APP_URL="$query_url"
assert_output_has_tokens 'Invalid runtime configuration: APP_URL is invalid'
assert_output_hides "$secret" "$query_url" "$query_value"
assert_rejected_without_secret 'Invalid runtime configuration: MYSQL_BIND_ADDRESS is invalid' MYSQL_BIND_ADDRESS=192.168.1.5
assert_rejected_without_secret 'Invalid runtime configuration: PHP_MEMORY_LIMIT is invalid' PHP_MEMORY_LIMIT='256M;auto_prepend_file=/tmp/pwn'
legacy_db_password='legacy-db-pass'
Expand Down
Loading