diff --git a/README.md b/README.md index 692ade8..44541d5 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,24 @@ See `/tests/README.md` for detailed testing documentation. 3. **Log Contacts**: Add new contacts with frequency, mode, RST, and other details 4. **View Logbook**: Browse your logged contacts on the dashboard +## Scheduled sync + +The cron endpoints (`/api/cron/*`) upload pending QSOs to LoTW and download +confirmations on a schedule. They require a `CRON_SECRET` environment variable +and reject every request that does not carry it — if the secret is unset the +endpoints fail closed with a 500. + +- **Vercel**: set `CRON_SECRET` in the project's environment variables. Vercel + automatically attaches `Authorization: Bearer $CRON_SECRET` to the cron + invocations defined in `vercel.json`; no other setup is needed. +- **Self-hosted**: set `CRON_SECRET` in your environment and call the + endpoints from your scheduler, e.g. a crontab entry: + + ```cron + 0 * * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-host/api/cron/lotw-upload + 30 * * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-host/api/cron/lotw-download + ``` + ## Cloudlog API Compatibility Nextlog provides full compatibility with Cloudlog's API, allowing you to use any third-party amateur radio software that supports Cloudlog integration. diff --git a/src/app/api/cron/lotw-download/route.ts b/src/app/api/cron/lotw-download/route.ts index c55f690..178b59a 100644 --- a/src/app/api/cron/lotw-download/route.ts +++ b/src/app/api/cron/lotw-download/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; +import { hasValidCronSecret } from '@/lib/cron-auth'; export async function GET(request: NextRequest) { try { @@ -18,22 +19,14 @@ export async function GET(request: NextRequest) { }, { status: 500 }); } - // Verify this is a legitimate cron request - const authHeader = request.headers.get('authorization'); - const expectedAuth = `Bearer ${process.env.CRON_SECRET}`; - - // For Vercel cron jobs, we need to be more flexible with authentication - // Vercel cron jobs run in a trusted environment but may not include the auth header - const isVercelCron = request.headers.get('user-agent')?.includes('vercel') || - request.headers.get('x-vercel-id') || - request.headers.get('host')?.includes('vercel'); - - if (!isVercelCron && authHeader !== expectedAuth) { - console.error('Authentication failed:', { - hasAuthHeader: !!authHeader, - hasCronSecret: !!process.env.CRON_SECRET, - isVercelCron - }); + // Verify this is a legitimate cron request. Fail closed when the secret + // is missing — Vercel only attaches the Authorization header when + // CRON_SECRET is set, and an unset secret must not mean "open endpoint". + if (!process.env.CRON_SECRET) { + console.error('CRON_SECRET is not configured; refusing cron request'); + return NextResponse.json({ error: 'CRON_SECRET not configured' }, { status: 500 }); + } + if (!hasValidCronSecret(request)) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -79,7 +72,8 @@ export async function GET(request: NextRequest) { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-Cron-Job': 'true', // Internal identifier + 'X-Cron-Job': 'true', // Cron-mode discriminator (grants nothing by itself) + 'Authorization': `Bearer ${process.env.CRON_SECRET}`, }, body: JSON.stringify({ station_id: station.id, diff --git a/src/app/api/cron/lotw-upload/route.ts b/src/app/api/cron/lotw-upload/route.ts index db27bf4..0788678 100644 --- a/src/app/api/cron/lotw-upload/route.ts +++ b/src/app/api/cron/lotw-upload/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; +import { hasValidCronSecret } from '@/lib/cron-auth'; export async function GET(request: NextRequest) { try { @@ -18,22 +19,14 @@ export async function GET(request: NextRequest) { }, { status: 500 }); } - // Verify this is a legitimate cron request - const authHeader = request.headers.get('authorization'); - const expectedAuth = `Bearer ${process.env.CRON_SECRET}`; - - // For Vercel cron jobs, we need to be more flexible with authentication - // Vercel cron jobs run in a trusted environment but may not include the auth header - const isVercelCron = request.headers.get('user-agent')?.includes('vercel') || - request.headers.get('x-vercel-id') || - request.headers.get('host')?.includes('vercel'); - - if (!isVercelCron && authHeader !== expectedAuth) { - console.error('Authentication failed:', { - hasAuthHeader: !!authHeader, - hasCronSecret: !!process.env.CRON_SECRET, - isVercelCron - }); + // Verify this is a legitimate cron request. Fail closed when the secret + // is missing — Vercel only attaches the Authorization header when + // CRON_SECRET is set, and an unset secret must not mean "open endpoint". + if (!process.env.CRON_SECRET) { + console.error('CRON_SECRET is not configured; refusing cron request'); + return NextResponse.json({ error: 'CRON_SECRET not configured' }, { status: 500 }); + } + if (!hasValidCronSecret(request)) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -108,7 +101,8 @@ export async function GET(request: NextRequest) { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-Cron-Job': 'true', // Internal identifier + 'X-Cron-Job': 'true', // Cron-mode discriminator (grants nothing by itself) + 'Authorization': `Bearer ${process.env.CRON_SECRET}`, }, body: JSON.stringify({ station_id: station.id, diff --git a/src/app/api/lotw/download/route.ts b/src/app/api/lotw/download/route.ts index a0d75e7..513c0c6 100644 --- a/src/app/api/lotw/download/route.ts +++ b/src/app/api/lotw/download/route.ts @@ -2,14 +2,21 @@ import { NextRequest, NextResponse } from 'next/server'; import { verifyToken } from '@/lib/auth'; +import { hasValidCronSecret } from '@/lib/cron-auth'; import { query } from '@/lib/db'; import { parseLoTWAdif, matchLoTWConfirmations, buildLoTWDownloadUrl, decryptString } from '@/lib/lotw'; import { LotwDownloadRequest, LotwDownloadResponse, ContactWithLoTW } from '@/types/lotw'; export async function POST(request: NextRequest) { try { - // Check if this is a cron job request - const isCronJob = request.headers.get('X-Cron-Job') === 'true'; + // Cron mode requires the valid CRON_SECRET Bearer token — the X-Cron-Job + // header is only a mode discriminator and grants nothing by itself + // (it is spoofable by any caller). + const cronHeaderPresent = request.headers.get('X-Cron-Job') === 'true'; + const isCronJob = cronHeaderPresent && hasValidCronSecret(request); + if (cronHeaderPresent && !isCronJob) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } let user = null; if (isCronJob) { diff --git a/src/app/api/lotw/upload/route.ts b/src/app/api/lotw/upload/route.ts index fa3e4b8..a3f09e5 100644 --- a/src/app/api/lotw/upload/route.ts +++ b/src/app/api/lotw/upload/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { verifyToken } from '@/lib/auth'; +import { hasValidCronSecret } from '@/lib/cron-auth'; import { query } from '@/lib/db'; import { buildSignedTq8, @@ -28,10 +29,16 @@ const LOTW_UPLOAD_ACCEPTED_REGEX = //i; export async function POST(request: NextRequest) { try { - // Check if this is a cron job request - const isCronJob = request.headers.get('X-Cron-Job') === 'true'; + // Cron mode requires the valid CRON_SECRET Bearer token — the X-Cron-Job + // header is only a mode discriminator and grants nothing by itself + // (it is spoofable by any caller). + const cronHeaderPresent = request.headers.get('X-Cron-Job') === 'true'; + const isCronJob = cronHeaderPresent && hasValidCronSecret(request); + if (cronHeaderPresent && !isCronJob) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } let user = null; - + if (isCronJob) { // For cron jobs, we'll get the user from the station_id user = null; // Will be set later diff --git a/src/lib/cron-auth.ts b/src/lib/cron-auth.ts new file mode 100644 index 0000000..17cb0ff --- /dev/null +++ b/src/lib/cron-auth.ts @@ -0,0 +1,15 @@ +// Strict cron authentication. +// +// Vercel attaches `Authorization: Bearer ${CRON_SECRET}` to cron invocations +// automatically when the CRON_SECRET env var is set on the project. +// Self-hosted operators pass the same header from their external scheduler +// (see README "Scheduled sync"). There is no trusted-host fallback: host and +// user-agent headers are caller-controlled and must never grant auth. + +import { NextRequest } from 'next/server'; + +export function hasValidCronSecret(request: NextRequest): boolean { + const secret = process.env.CRON_SECRET; + if (!secret) return false; + return request.headers.get('authorization') === `Bearer ${secret}`; +}