-
Notifications
You must be signed in to change notification settings - Fork 0
security: harden server bindings, auth gates, and error handling #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -23,9 +23,13 @@ function initDb(): Database.Database { | |||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Create database connection | ||||||||||||||||||||||||||||||||
| const isNew = !fs.existsSync(DB_PATH); | ||||||||||||||||||||||||||||||||
| db = new Database(DB_PATH, { | ||||||||||||||||||||||||||||||||
| verbose: process.env.SQLITE_VERBOSE ? console.log : undefined, | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
| if (isNew) { | ||||||||||||||||||||||||||||||||
| fs.chmodSync(DB_PATH, 0o600); | ||||||||||||||||||||||||||||||||
|
Comment on lines
+30
to
+31
|
||||||||||||||||||||||||||||||||
| if (isNew) { | |
| fs.chmodSync(DB_PATH, 0o600); | |
| // Best-effort: ensure database file permissions are restrictive (owner read/write only) | |
| try { | |
| if (fs.existsSync(DB_PATH)) { | |
| const stats = fs.statSync(DB_PATH); | |
| const mode = stats.mode & 0o777; | |
| // If group/other have any permissions, or owner is missing read/write, tighten to 0o600 | |
| if ((mode & 0o077) !== 0 || (mode & 0o600) !== 0o600) { | |
| fs.chmodSync(DB_PATH, 0o600); | |
| } | |
| } | |
| } catch { | |
| // Ignore errors (e.g., unsupported filesystem/platform or insufficient privileges) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -35,7 +35,11 @@ import { logger } from './lib/logger.js'; | |||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:6373'; | ||||||||||||||||||||||||||||||||||
| const corsOrigin = CORS_ORIGIN.includes(',') | ||||||||||||||||||||||||||||||||||
| ? CORS_ORIGIN.split(',').map(o => o.trim()) | ||||||||||||||||||||||||||||||||||
| ? CORS_ORIGIN.split(',').map(o => { | ||||||||||||||||||||||||||||||||||
| const trimmed = o.trim(); | ||||||||||||||||||||||||||||||||||
| new URL(trimmed); // throws on invalid origin | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
| new URL(trimmed); // throws on invalid origin | |
| if (!trimmed) { | |
| throw new Error( | |
| `Invalid CORS_ORIGIN configuration: found empty origin entry in CORS_ORIGIN='${CORS_ORIGIN}'. ` + | |
| 'Remove empty entries (for example, trailing commas).' | |
| ); | |
| } | |
| try { | |
| // Validate origin; URL constructor will throw on invalid values | |
| new URL(trimmed); | |
| } catch (err) { | |
| const message = (err as Error).message || String(err); | |
| throw new Error( | |
| `Invalid CORS_ORIGIN configuration: origin '${trimmed}' from CORS_ORIGIN='${CORS_ORIGIN}' is not a valid URL: ${message}` | |
| ); | |
| } |
Copilot
AI
Mar 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CORS_ORIGIN validation/normalization is only applied when the env var contains a comma. For a single origin, whitespace and invalid URLs won’t be caught, and leading/trailing spaces will cause origin mismatches. Consider always splitting (even for a single value), trimming, validating with URL parsing, and using the parsed origin so values like http://host:port/path don’t silently misconfigure CORS.
Copilot
AI
Mar 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The shutdown handler calls httpServer.close(), which waits for all open connections to finish. With SSE endpoints in this server, the close callback may never run, leading to the forced process.exit(1) after 5s even during a normal shutdown. Consider tracking sockets and destroying them on shutdown (or using httpServer.closeAllConnections()/closeIdleConnections where available) so shutdown is predictable and can exit cleanly.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -210,6 +210,8 @@ router.get('/photos/:personId/exists', async (req: Request, res: Response) => { | |||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Get FamilySearch authentication token from browser session | ||||||||||||||||||||||||||||||||||
| // Security note: This endpoint returns an auth token in the JSON response. | ||||||||||||||||||||||||||||||||||
| // Acceptable because SparseTree is a local-only tool and FS tokens are short-lived. | ||||||||||||||||||||||||||||||||||
| router.get('/token', async (_req: Request, res: Response) => { | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+214
to
215
|
||||||||||||||||||||||||||||||||||
| // Acceptable because SparseTree is a local-only tool and FS tokens are short-lived. | |
| router.get('/token', async (_req: Request, res: Response) => { | |
| // It is intended to be used only from localhost and must not be exposed externally. | |
| router.get('/token', async (req: Request, res: Response) => { | |
| // Enforce localhost-only access to prevent accidental exposure when server is not bound | |
| // exclusively to a loopback interface. | |
| const remoteAddr = req.ip || req.socket.remoteAddress || ''; | |
| const isLocalhost = | |
| remoteAddr === '127.0.0.1' || | |
| remoteAddr === '::1' || | |
| remoteAddr.startsWith('::ffff:127.0.0.1'); | |
| if (!isLocalhost) { | |
| res.status(403).json({ success: false, error: 'Token endpoint is only accessible from localhost' }); | |
| return; | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| import { Router, Request, Response } from 'express'; | ||
| import crypto from 'crypto'; | ||
| import type { GenealogyProviderConfig, PlatformType } from '@fsf/shared'; | ||
| import { genealogyProviderService } from '../services/genealogy-provider.service.js'; | ||
| import { pickFields } from '../utils/validation.js'; | ||
|
|
@@ -60,7 +61,7 @@ router.post('/', (req: Request, res: Response) => { | |
|
|
||
| // Generate ID if not provided | ||
| if (!config.id) { | ||
| config.id = config.platform + '-' + Date.now(); | ||
| config.id = config.platform + '-' + crypto.randomUUID(); | ||
| } | ||
|
Comment on lines
62
to
65
|
||
|
|
||
| // Set defaults if not provided | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fs.chmodSync(DB_PATH, 0o600)can throw (e.g., permission issues) and will crash server startup. Consider wrapping in try/catch and logging a warning, and/or applying permissions in a best-effort way rather than failing hard.