fix(runtime): harden smoke and credential boundaries

This commit is contained in:
2026-08-04 16:05:06 +00:00
parent 09d849de07
commit c49989631f
16 changed files with 517 additions and 8 deletions
+2 -1
View File
@@ -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"]
+8 -1
View File
@@ -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: [
+28
View File
@@ -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
+10
View File
@@ -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
+58
View File
@@ -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.`);
}
}
+112
View File
@@ -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.');
}
}