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
|
// 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);
|
const site = findAuthorizedSite(from, to);
|
||||||
if (!site) {
|
if (site === null) {
|
||||||
logger.info({ event: 'sms.unauthorized', from: maskPhone(from), to: maskPhone(to), messageId }, 'Unauthorized sender');
|
logger.info({ event: 'sms.unauthorized', from: maskPhone(from), to: maskPhone(to), messageId }, 'Unauthorized sender');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const phoneHash = hashPhone(from);
|
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
|
// Idempotency check
|
||||||
if (messageId && !claimOnce(`sms:${messageId}`, 3600)) {
|
if (messageId && !claimOnce(`sms:${messageId}`, 3600)) {
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ import path from 'node:path';
|
|||||||
import { smsSitesConfigSchema, type SmsSitesConfig } from '@dynamic-sites/shared';
|
import { smsSitesConfigSchema, type SmsSitesConfig } from '@dynamic-sites/shared';
|
||||||
import { logger } from '../logger.js';
|
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 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 cachedConfig: SmsSitesConfig | null = null;
|
||||||
let configLoadedAt = 0;
|
let configLoadedAt = 0;
|
||||||
@@ -14,40 +19,57 @@ function loadConfig(): SmsSitesConfig | null {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (cachedConfig && now - configLoadedAt < CONFIG_TTL_MS) return cachedConfig;
|
if (cachedConfig && now - configLoadedAt < CONFIG_TTL_MS) return cachedConfig;
|
||||||
|
|
||||||
|
for (const configPath of CONFIG_SEARCH_PATHS) {
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
const result = smsSitesConfigSchema.safeParse(parsed);
|
const result = smsSitesConfigSchema.safeParse(parsed);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
logger.warn({ event: 'sms.config_invalid', errors: result.error.message }, 'Invalid SMS sites config');
|
logger.warn({ event: 'sms.config_invalid', path: configPath, errors: result.error.message }, 'Invalid SMS sites config');
|
||||||
return cachedConfig;
|
continue;
|
||||||
}
|
}
|
||||||
cachedConfig = result.data;
|
cachedConfig = result.data;
|
||||||
configLoadedAt = now;
|
configLoadedAt = now;
|
||||||
|
logger.info({ event: 'sms.config_loaded', path: configPath, sites: cachedConfig.sites.length }, 'SMS sites config loaded');
|
||||||
return cachedConfig;
|
return cachedConfig;
|
||||||
} catch (err) {
|
} catch {
|
||||||
logger.warn({ event: 'sms.config_missing', error: (err as Error).message }, 'Could not load SMS sites config');
|
// Try next path
|
||||||
return cachedConfig;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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).
|
* 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.
|
* Used to filter out Telnyx delivery receipts / echo of our own sent messages.
|
||||||
*/
|
*/
|
||||||
export function isOwnNumber(phone: string): boolean {
|
export function isOwnNumber(phone: string): boolean {
|
||||||
const config = loadConfig();
|
return getOwnNumbers().includes(phone);
|
||||||
if (!config) return false;
|
|
||||||
return config.sites.some(site => site.phoneNumber === phone);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a sender is authorized to make edits for a given site.
|
* Check if a sender is authorized to make edits for a given site.
|
||||||
* Returns the matching site config if authorized, or null.
|
* 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();
|
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 =>
|
const site = config.sites.find(site =>
|
||||||
site.phoneNumber === to && site.allowedSenders.includes(from)
|
site.phoneNumber === to && site.allowedSenders.includes(from)
|
||||||
|
|||||||
Reference in New Issue
Block a user