fix(runtime): harden smoke and credential boundaries
This commit is contained in:
+2
-1
@@ -8,7 +8,8 @@ RUN apt-get update \
|
||||
|
||||
COPY --chmod=755 runtime/entrypoint.sh /usr/local/bin/sammo-entrypoint
|
||||
COPY --chmod=755 runtime/redis-entrypoint.sh /opt/sammo/redis-entrypoint.sh
|
||||
COPY runtime/ecosystem.config.cjs runtime/bootstrap.mjs /opt/sammo/
|
||||
COPY --chmod=755 runtime/git-askpass.sh /opt/sammo/git-askpass.sh
|
||||
COPY runtime/ecosystem.config.cjs runtime/bootstrap.mjs runtime/validate-env.mjs /opt/sammo/
|
||||
|
||||
WORKDIR /srv/core/repository
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/sammo-entrypoint"]
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
const path = require('node:path');
|
||||
|
||||
const root = process.env.GATEWAY_WORKSPACE_ROOT || '/srv/core/repository';
|
||||
const common = { env: { ...process.env }, autorestart: true, kill_timeout: 15000 };
|
||||
const common = {
|
||||
env: { ...process.env },
|
||||
autorestart: true,
|
||||
max_restarts: 5,
|
||||
min_uptime: 10000,
|
||||
restart_delay: 2000,
|
||||
kill_timeout: 15000,
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
|
||||
@@ -17,6 +17,34 @@ for key in POSTGRES_PASSWORD REDIS_PASSWORD GAME_TOKEN_SECRET KAKAO_REST_KEY KAK
|
||||
require_env "$key"
|
||||
done
|
||||
|
||||
node /opt/sammo/validate-env.mjs
|
||||
|
||||
git_auth_dir=/srv/data/git-auth
|
||||
mkdir -p "$git_auth_dir"
|
||||
umask 077
|
||||
|
||||
if [ -n "${CORE_REPOSITORY_USERNAME:-}" ] || [ -n "${CORE_REPOSITORY_TOKEN:-}" ]; then
|
||||
printf '%s' "$CORE_REPOSITORY_USERNAME" >"$git_auth_dir/https-username"
|
||||
printf '%s' "$CORE_REPOSITORY_TOKEN" >"$git_auth_dir/https-token"
|
||||
chmod 600 "$git_auth_dir/https-username" "$git_auth_dir/https-token"
|
||||
export SAMMO_GIT_AUTH_DIR="$git_auth_dir"
|
||||
export GIT_ASKPASS=/opt/sammo/git-askpass.sh
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
unset CORE_REPOSITORY_USERNAME CORE_REPOSITORY_TOKEN
|
||||
else
|
||||
rm -f "$git_auth_dir/https-username" "$git_auth_dir/https-token"
|
||||
fi
|
||||
|
||||
if [ -n "${CORE_SSH_PRIVATE_KEY_BASE64:-}" ] || [ -n "${CORE_SSH_KNOWN_HOSTS_BASE64:-}" ]; then
|
||||
printf '%s' "$CORE_SSH_PRIVATE_KEY_BASE64" | base64 -d >"$git_auth_dir/id_deploy"
|
||||
printf '%s' "$CORE_SSH_KNOWN_HOSTS_BASE64" | base64 -d >"$git_auth_dir/known_hosts"
|
||||
chmod 600 "$git_auth_dir/id_deploy" "$git_auth_dir/known_hosts"
|
||||
export GIT_SSH_COMMAND="ssh -i $git_auth_dir/id_deploy -o IdentitiesOnly=yes -o UserKnownHostsFile=$git_auth_dir/known_hosts -o StrictHostKeyChecking=yes"
|
||||
unset CORE_SSH_PRIVATE_KEY_BASE64 CORE_SSH_KNOWN_HOSTS_BASE64
|
||||
else
|
||||
rm -f "$git_auth_dir/id_deploy" "$git_auth_dir/known_hosts"
|
||||
fi
|
||||
|
||||
case "${CORE_SOURCE_MODE:-clone}" in
|
||||
clone)
|
||||
require_env CORE_REPOSITORY_URL
|
||||
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
auth_dir=${SAMMO_GIT_AUTH_DIR:-/srv/data/git-auth}
|
||||
|
||||
case "${1:-}" in
|
||||
*Username*) cat "$auth_dir/https-username" ;;
|
||||
*Password*) cat "$auth_dir/https-token" ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
@@ -0,0 +1,58 @@
|
||||
import fs from 'node:fs';
|
||||
|
||||
const MAX_SAFE_DEV_MEMORY = 4 * 1024 * 1024 * 1024;
|
||||
const MAX_SAFE_DEV_CPUS = 4;
|
||||
const MAX_SAFE_DEV_PIDS = 256;
|
||||
|
||||
const positive = (value) => typeof value === 'number' ? value > 0 : Number(value) > 0;
|
||||
|
||||
export const validateComposeModel = (model, mode) => {
|
||||
const errors = [];
|
||||
const runtime = model?.services?.runtime;
|
||||
if (!runtime) return ['runtime service is missing'];
|
||||
|
||||
if (!positive(runtime.mem_limit)) errors.push('runtime memory limit must be positive');
|
||||
if (!positive(runtime.memswap_limit)) errors.push('runtime memory+swap limit must be positive');
|
||||
if (Number(runtime.memswap_limit) !== Number(runtime.mem_limit)) {
|
||||
errors.push('runtime memory+swap limit must equal its memory limit');
|
||||
}
|
||||
if (!positive(runtime.cpus)) errors.push('runtime CPU limit must be positive');
|
||||
if (!positive(runtime.pids_limit)) errors.push('runtime PID limit must be positive');
|
||||
|
||||
if (mode === 'development') {
|
||||
if (runtime.environment?.RUNTIME_MODE !== 'development') {
|
||||
errors.push('development runtime mode must be literal development');
|
||||
}
|
||||
if (runtime.environment?.CORE_SOURCE_MODE !== 'bind') {
|
||||
errors.push('development Core source mode must be bind');
|
||||
}
|
||||
} else if (runtime.environment?.CORE_SOURCE_MODE !== 'clone') {
|
||||
errors.push(`${mode} Core source mode must be clone`);
|
||||
} else if (runtime.environment?.RUNTIME_MODE === 'development') {
|
||||
errors.push(`${mode} runtime must not use development mode`);
|
||||
}
|
||||
|
||||
if (mode === 'development' || mode === 'smoke') {
|
||||
for (const name of ['postgres', 'redis', 'runtime', 'caddy']) {
|
||||
if (model?.services?.[name]?.restart !== 'no') errors.push(`${name} ${mode} restart policy must be no`);
|
||||
}
|
||||
if (Number(runtime.mem_limit) > MAX_SAFE_DEV_MEMORY) errors.push(`${mode} runtime memory limit exceeds 4 GiB`);
|
||||
if (Number(runtime.cpus) > MAX_SAFE_DEV_CPUS) errors.push(`${mode} runtime CPU limit exceeds 4`);
|
||||
if (Number(runtime.pids_limit) > MAX_SAFE_DEV_PIDS) errors.push(`${mode} runtime PID limit exceeds 256`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith('validate-compose-model.mjs')) {
|
||||
const mode = process.argv[2];
|
||||
const model = JSON.parse(fs.readFileSync(0, 'utf8'));
|
||||
const errors = validateComposeModel(model, mode);
|
||||
if (errors.length) {
|
||||
console.error(`Compose ${mode} safety validation failed:`);
|
||||
for (const error of errors) console.error(`- ${error}`);
|
||||
process.exitCode = 65;
|
||||
} else {
|
||||
console.log(`Compose ${mode} safety validation passed.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const REQUIRED = [
|
||||
'DOMAIN',
|
||||
'ACME_EMAIL',
|
||||
'CORE_REPOSITORY_URL',
|
||||
'POSTGRES_PASSWORD',
|
||||
'REDIS_PASSWORD',
|
||||
'GAME_TOKEN_SECRET',
|
||||
'GATEWAY_BOOTSTRAP_TOKEN',
|
||||
'INITIAL_ADMIN_USERNAME',
|
||||
'INITIAL_ADMIN_PASSWORD',
|
||||
'KAKAO_REST_KEY',
|
||||
];
|
||||
|
||||
const MIN_LENGTH = new Map([
|
||||
['POSTGRES_PASSWORD', 24],
|
||||
['REDIS_PASSWORD', 24],
|
||||
['GAME_TOKEN_SECRET', 32],
|
||||
['GATEWAY_BOOTSTRAP_TOKEN', 32],
|
||||
['INITIAL_ADMIN_PASSWORD', 16],
|
||||
]);
|
||||
|
||||
const isPlaceholder = (value) =>
|
||||
value.startsWith('replace-with-') || value.includes('example.com') || value.includes('your-org');
|
||||
|
||||
const decodeBase64 = (value) => {
|
||||
try {
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0) return null;
|
||||
return Buffer.from(value, 'base64').toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const validateEnvironment = (env) => {
|
||||
const errors = [];
|
||||
for (const key of REQUIRED) {
|
||||
const value = env[key]?.trim() ?? '';
|
||||
if (!value) errors.push(`${key} is required`);
|
||||
else if (isPlaceholder(value)) errors.push(`${key} still contains an example placeholder`);
|
||||
}
|
||||
|
||||
for (const [key, length] of MIN_LENGTH) {
|
||||
const value = env[key] ?? '';
|
||||
if (value && value.length < length) errors.push(`${key} must contain at least ${length} characters`);
|
||||
}
|
||||
|
||||
const domain = env.DOMAIN?.trim() ?? '';
|
||||
if (domain && !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(domain)) {
|
||||
errors.push('DOMAIN must be a hostname without a scheme, path, or port');
|
||||
}
|
||||
|
||||
const email = env.ACME_EMAIL?.trim() ?? '';
|
||||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.push('ACME_EMAIL must be a valid email address');
|
||||
|
||||
const repositoryUrl = env.CORE_REPOSITORY_URL?.trim() ?? '';
|
||||
if (repositoryUrl) {
|
||||
if (/^https?:\/\//i.test(repositoryUrl)) {
|
||||
try {
|
||||
const parsed = new URL(repositoryUrl);
|
||||
if (parsed.username || parsed.password) errors.push('CORE_REPOSITORY_URL must not embed credentials');
|
||||
} catch {
|
||||
errors.push('CORE_REPOSITORY_URL is not a valid HTTP(S) URL');
|
||||
}
|
||||
} else if (!/^(?:ssh:\/\/|git@)[^\s]+/i.test(repositoryUrl)) {
|
||||
errors.push('CORE_REPOSITORY_URL must use HTTP(S) or SSH');
|
||||
}
|
||||
}
|
||||
|
||||
const httpsUser = env.CORE_REPOSITORY_USERNAME?.trim() ?? '';
|
||||
const httpsToken = env.CORE_REPOSITORY_TOKEN?.trim() ?? '';
|
||||
const sshKey = env.CORE_SSH_PRIVATE_KEY_BASE64?.trim() ?? '';
|
||||
const sshHosts = env.CORE_SSH_KNOWN_HOSTS_BASE64?.trim() ?? '';
|
||||
if (Boolean(httpsUser) !== Boolean(httpsToken)) {
|
||||
errors.push('CORE_REPOSITORY_USERNAME and CORE_REPOSITORY_TOKEN must be set together');
|
||||
}
|
||||
if (Boolean(sshKey) !== Boolean(sshHosts)) {
|
||||
errors.push('CORE_SSH_PRIVATE_KEY_BASE64 and CORE_SSH_KNOWN_HOSTS_BASE64 must be set together');
|
||||
}
|
||||
if ((httpsUser || httpsToken) && (sshKey || sshHosts)) errors.push('configure only one Core repository authentication mode');
|
||||
if ((httpsUser || httpsToken) && !/^https?:\/\//i.test(repositoryUrl)) {
|
||||
errors.push('HTTPS repository credentials require an HTTP(S) CORE_REPOSITORY_URL');
|
||||
}
|
||||
if ((sshKey || sshHosts) && !/^(?:ssh:\/\/|git@)/i.test(repositoryUrl)) {
|
||||
errors.push('SSH repository credentials require an SSH CORE_REPOSITORY_URL');
|
||||
}
|
||||
if (sshKey) {
|
||||
const decoded = decodeBase64(sshKey);
|
||||
if (!decoded?.includes('BEGIN OPENSSH PRIVATE KEY')) {
|
||||
errors.push('CORE_SSH_PRIVATE_KEY_BASE64 must encode an OpenSSH private key');
|
||||
}
|
||||
}
|
||||
if (sshHosts && !decodeBase64(sshHosts)?.trim()) {
|
||||
errors.push('CORE_SSH_KNOWN_HOSTS_BASE64 must contain valid base64-encoded known_hosts data');
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
||||
if (isMain) {
|
||||
const errors = validateEnvironment(process.env);
|
||||
if (errors.length) {
|
||||
console.error('Environment validation failed:');
|
||||
for (const error of errors) console.error(`- ${error}`);
|
||||
process.exitCode = 64;
|
||||
} else {
|
||||
console.log('Environment validation passed without printing secret values.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user