Fix SMS config path resolution and fail-open when config missing
The config loader now searches multiple paths for sms-sites.json (REPO_ROOT-based, /app/, and CWD-relative) so it works regardless of deployment environment. When config can't be loaded, the auth check fails open (allows messages through) rather than blocking everything. The isOwnNumber check still returns false when config is unavailable since we can't identify our own numbers without it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -44,14 +44,17 @@ async function handleInbound(body: unknown, deps: WebhookSmsRouterDeps) {
|
||||
}
|
||||
|
||||
// Check if sender is authorized for this site
|
||||
// - null: config loaded, sender NOT authorized → block
|
||||
// - undefined: config unavailable → allow through (fail open)
|
||||
// - object: config loaded, sender authorized → allow
|
||||
const site = findAuthorizedSite(from, to);
|
||||
if (!site) {
|
||||
if (site === null) {
|
||||
logger.info({ event: 'sms.unauthorized', from: maskPhone(from), to: maskPhone(to), messageId }, 'Unauthorized sender');
|
||||
return;
|
||||
}
|
||||
|
||||
const phoneHash = hashPhone(from);
|
||||
logger.info({ event: 'sms.received', from: maskPhone(from), hasMedia, messageId, siteId: site.siteId }, 'Inbound SMS');
|
||||
logger.info({ event: 'sms.received', from: maskPhone(from), hasMedia, messageId, siteId: site?.siteId }, 'Inbound SMS');
|
||||
|
||||
// Idempotency check
|
||||
if (messageId && !claimOnce(`sms:${messageId}`, 3600)) {
|
||||
|
||||
@@ -3,8 +3,13 @@ import path from 'node:path';
|
||||
import { smsSitesConfigSchema, type SmsSitesConfig } from '@dynamic-sites/shared';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
// Try multiple locations for the config file (Docker CWD vs REPO_ROOT)
|
||||
const REPO_ROOT = process.env.REPO_ROOT || '.';
|
||||
const CONFIG_PATH = path.join(REPO_ROOT, 'config/sms-sites.json');
|
||||
const CONFIG_SEARCH_PATHS = [
|
||||
path.join(REPO_ROOT, 'config/sms-sites.json'),
|
||||
path.resolve('/app/config/sms-sites.json'),
|
||||
path.resolve('config/sms-sites.json'),
|
||||
];
|
||||
|
||||
let cachedConfig: SmsSitesConfig | null = null;
|
||||
let configLoadedAt = 0;
|
||||
@@ -14,40 +19,57 @@ function loadConfig(): SmsSitesConfig | null {
|
||||
const now = Date.now();
|
||||
if (cachedConfig && now - configLoadedAt < CONFIG_TTL_MS) return cachedConfig;
|
||||
|
||||
for (const configPath of CONFIG_SEARCH_PATHS) {
|
||||
try {
|
||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const result = smsSitesConfigSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
logger.warn({ event: 'sms.config_invalid', errors: result.error.message }, 'Invalid SMS sites config');
|
||||
return cachedConfig;
|
||||
logger.warn({ event: 'sms.config_invalid', path: configPath, errors: result.error.message }, 'Invalid SMS sites config');
|
||||
continue;
|
||||
}
|
||||
cachedConfig = result.data;
|
||||
configLoadedAt = now;
|
||||
logger.info({ event: 'sms.config_loaded', path: configPath, sites: cachedConfig.sites.length }, 'SMS sites config loaded');
|
||||
return cachedConfig;
|
||||
} catch (err) {
|
||||
logger.warn({ event: 'sms.config_missing', error: (err as Error).message }, 'Could not load SMS sites config');
|
||||
return cachedConfig;
|
||||
} catch {
|
||||
// Try next path
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn({ event: 'sms.config_missing', paths: CONFIG_SEARCH_PATHS }, 'Could not load SMS sites config from any path');
|
||||
return cachedConfig; // Return stale cache if available, otherwise null
|
||||
}
|
||||
|
||||
/** All phone numbers this system sends from (Telnyx numbers). */
|
||||
function getOwnNumbers(): string[] {
|
||||
const config = loadConfig();
|
||||
if (!config) return [];
|
||||
return config.sites.map(site => site.phoneNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a phone number is one of our own system numbers (the Telnyx numbers we send from).
|
||||
* Used to filter out Telnyx delivery receipts / echo of our own sent messages.
|
||||
*/
|
||||
export function isOwnNumber(phone: string): boolean {
|
||||
const config = loadConfig();
|
||||
if (!config) return false;
|
||||
return config.sites.some(site => site.phoneNumber === phone);
|
||||
return getOwnNumbers().includes(phone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a sender is authorized to make edits for a given site.
|
||||
* Returns the matching site config if authorized, or null.
|
||||
* Returns null (blocking) when config is available but sender isn't authorized.
|
||||
* Returns undefined (allowing) when config can't be loaded at all.
|
||||
*/
|
||||
export function findAuthorizedSite(from: string, to: string): { siteId: string; repoRoot: string } | null {
|
||||
export function findAuthorizedSite(from: string, to: string): { siteId: string; repoRoot: string } | null | undefined {
|
||||
const config = loadConfig();
|
||||
if (!config) return null;
|
||||
|
||||
// If config can't be loaded, fail OPEN — don't block messages
|
||||
if (!config) {
|
||||
logger.warn({ event: 'sms.config_unavailable' }, 'SMS config unavailable, allowing sender through');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const site = config.sites.find(site =>
|
||||
site.phoneNumber === to && site.allowedSenders.includes(from)
|
||||
|
||||
Reference in New Issue
Block a user