Skip to content

Commit 1d9a59b

Browse files
patrickrbclaude
andcommitted
fix(security): require CRON_SECRET on cron + LoTW sync endpoints
The cron routes trusted any request whose host/user-agent/x-vercel-id header mentioned vercel, which disabled the CRON_SECRET check on every *.vercel.app deployment. Separately, /api/lotw/{upload,download} skipped auth entirely on a spoofable X-Cron-Job: true header, letting anonymous callers trigger uploads/downloads for any station_id. - New src/lib/cron-auth.ts: strict Bearer CRON_SECRET check, no fallbacks. - Cron routes require the Bearer token unconditionally and fail closed (500) when CRON_SECRET is unset. Vercel attaches the header automatically when the env var exists. - X-Cron-Job is now only a mode discriminator: cron mode additionally requires the valid Bearer token; the cron routes' internal fetches send it. - README: "Scheduled sync" section for Vercel + self-hosted crontab setup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 56c80cd commit 1d9a59b

6 files changed

Lines changed: 74 additions & 39 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,24 @@ See `/tests/README.md` for detailed testing documentation.
160160
3. **Log Contacts**: Add new contacts with frequency, mode, RST, and other details
161161
4. **View Logbook**: Browse your logged contacts on the dashboard
162162

163+
## Scheduled sync
164+
165+
The cron endpoints (`/api/cron/*`) upload pending QSOs to LoTW and download
166+
confirmations on a schedule. They require a `CRON_SECRET` environment variable
167+
and reject every request that does not carry it — if the secret is unset the
168+
endpoints fail closed with a 500.
169+
170+
- **Vercel**: set `CRON_SECRET` in the project's environment variables. Vercel
171+
automatically attaches `Authorization: Bearer $CRON_SECRET` to the cron
172+
invocations defined in `vercel.json`; no other setup is needed.
173+
- **Self-hosted**: set `CRON_SECRET` in your environment and call the
174+
endpoints from your scheduler, e.g. a crontab entry:
175+
176+
```cron
177+
0 * * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-host/api/cron/lotw-upload
178+
30 * * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-host/api/cron/lotw-download
179+
```
180+
163181
## Cloudlog API Compatibility
164182

165183
Nextlog provides full compatibility with Cloudlog's API, allowing you to use any third-party amateur radio software that supports Cloudlog integration.

src/app/api/cron/lotw-download/route.ts

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import { NextRequest, NextResponse } from 'next/server';
55
import { query } from '@/lib/db';
6+
import { hasValidCronSecret } from '@/lib/cron-auth';
67

78
export async function GET(request: NextRequest) {
89
try {
@@ -18,22 +19,14 @@ export async function GET(request: NextRequest) {
1819
}, { status: 500 });
1920
}
2021

21-
// Verify this is a legitimate cron request
22-
const authHeader = request.headers.get('authorization');
23-
const expectedAuth = `Bearer ${process.env.CRON_SECRET}`;
24-
25-
// For Vercel cron jobs, we need to be more flexible with authentication
26-
// Vercel cron jobs run in a trusted environment but may not include the auth header
27-
const isVercelCron = request.headers.get('user-agent')?.includes('vercel') ||
28-
request.headers.get('x-vercel-id') ||
29-
request.headers.get('host')?.includes('vercel');
30-
31-
if (!isVercelCron && authHeader !== expectedAuth) {
32-
console.error('Authentication failed:', {
33-
hasAuthHeader: !!authHeader,
34-
hasCronSecret: !!process.env.CRON_SECRET,
35-
isVercelCron
36-
});
22+
// Verify this is a legitimate cron request. Fail closed when the secret
23+
// is missing — Vercel only attaches the Authorization header when
24+
// CRON_SECRET is set, and an unset secret must not mean "open endpoint".
25+
if (!process.env.CRON_SECRET) {
26+
console.error('CRON_SECRET is not configured; refusing cron request');
27+
return NextResponse.json({ error: 'CRON_SECRET not configured' }, { status: 500 });
28+
}
29+
if (!hasValidCronSecret(request)) {
3730
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
3831
}
3932

@@ -79,7 +72,8 @@ export async function GET(request: NextRequest) {
7972
method: 'POST',
8073
headers: {
8174
'Content-Type': 'application/json',
82-
'X-Cron-Job': 'true', // Internal identifier
75+
'X-Cron-Job': 'true', // Cron-mode discriminator (grants nothing by itself)
76+
'Authorization': `Bearer ${process.env.CRON_SECRET}`,
8377
},
8478
body: JSON.stringify({
8579
station_id: station.id,

src/app/api/cron/lotw-upload/route.ts

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import { NextRequest, NextResponse } from 'next/server';
55
import { query } from '@/lib/db';
6+
import { hasValidCronSecret } from '@/lib/cron-auth';
67

78
export async function GET(request: NextRequest) {
89
try {
@@ -18,22 +19,14 @@ export async function GET(request: NextRequest) {
1819
}, { status: 500 });
1920
}
2021

21-
// Verify this is a legitimate cron request
22-
const authHeader = request.headers.get('authorization');
23-
const expectedAuth = `Bearer ${process.env.CRON_SECRET}`;
24-
25-
// For Vercel cron jobs, we need to be more flexible with authentication
26-
// Vercel cron jobs run in a trusted environment but may not include the auth header
27-
const isVercelCron = request.headers.get('user-agent')?.includes('vercel') ||
28-
request.headers.get('x-vercel-id') ||
29-
request.headers.get('host')?.includes('vercel');
30-
31-
if (!isVercelCron && authHeader !== expectedAuth) {
32-
console.error('Authentication failed:', {
33-
hasAuthHeader: !!authHeader,
34-
hasCronSecret: !!process.env.CRON_SECRET,
35-
isVercelCron
36-
});
22+
// Verify this is a legitimate cron request. Fail closed when the secret
23+
// is missing — Vercel only attaches the Authorization header when
24+
// CRON_SECRET is set, and an unset secret must not mean "open endpoint".
25+
if (!process.env.CRON_SECRET) {
26+
console.error('CRON_SECRET is not configured; refusing cron request');
27+
return NextResponse.json({ error: 'CRON_SECRET not configured' }, { status: 500 });
28+
}
29+
if (!hasValidCronSecret(request)) {
3730
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
3831
}
3932

@@ -108,7 +101,8 @@ export async function GET(request: NextRequest) {
108101
method: 'POST',
109102
headers: {
110103
'Content-Type': 'application/json',
111-
'X-Cron-Job': 'true', // Internal identifier
104+
'X-Cron-Job': 'true', // Cron-mode discriminator (grants nothing by itself)
105+
'Authorization': `Bearer ${process.env.CRON_SECRET}`,
112106
},
113107
body: JSON.stringify({
114108
station_id: station.id,

src/app/api/lotw/download/route.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,21 @@
22

33
import { NextRequest, NextResponse } from 'next/server';
44
import { verifyToken } from '@/lib/auth';
5+
import { hasValidCronSecret } from '@/lib/cron-auth';
56
import { query } from '@/lib/db';
67
import { parseLoTWAdif, matchLoTWConfirmations, buildLoTWDownloadUrl, decryptString } from '@/lib/lotw';
78
import { LotwDownloadRequest, LotwDownloadResponse, ContactWithLoTW } from '@/types/lotw';
89

910
export async function POST(request: NextRequest) {
1011
try {
11-
// Check if this is a cron job request
12-
const isCronJob = request.headers.get('X-Cron-Job') === 'true';
12+
// Cron mode requires the valid CRON_SECRET Bearer token — the X-Cron-Job
13+
// header is only a mode discriminator and grants nothing by itself
14+
// (it is spoofable by any caller).
15+
const cronHeaderPresent = request.headers.get('X-Cron-Job') === 'true';
16+
const isCronJob = cronHeaderPresent && hasValidCronSecret(request);
17+
if (cronHeaderPresent && !isCronJob) {
18+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
19+
}
1320
let user = null;
1421

1522
if (isCronJob) {

src/app/api/lotw/upload/route.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { NextRequest, NextResponse } from 'next/server';
44
import { verifyToken } from '@/lib/auth';
5+
import { hasValidCronSecret } from '@/lib/cron-auth';
56
import { query } from '@/lib/db';
67
import {
78
buildSignedTq8,
@@ -28,10 +29,16 @@ const LOTW_UPLOAD_ACCEPTED_REGEX = /<!--\s*\.UPL\.\s*accepted\s*-->/i;
2829

2930
export async function POST(request: NextRequest) {
3031
try {
31-
// Check if this is a cron job request
32-
const isCronJob = request.headers.get('X-Cron-Job') === 'true';
32+
// Cron mode requires the valid CRON_SECRET Bearer token — the X-Cron-Job
33+
// header is only a mode discriminator and grants nothing by itself
34+
// (it is spoofable by any caller).
35+
const cronHeaderPresent = request.headers.get('X-Cron-Job') === 'true';
36+
const isCronJob = cronHeaderPresent && hasValidCronSecret(request);
37+
if (cronHeaderPresent && !isCronJob) {
38+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
39+
}
3340
let user = null;
34-
41+
3542
if (isCronJob) {
3643
// For cron jobs, we'll get the user from the station_id
3744
user = null; // Will be set later

src/lib/cron-auth.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Strict cron authentication.
2+
//
3+
// Vercel attaches `Authorization: Bearer ${CRON_SECRET}` to cron invocations
4+
// automatically when the CRON_SECRET env var is set on the project.
5+
// Self-hosted operators pass the same header from their external scheduler
6+
// (see README "Scheduled sync"). There is no trusted-host fallback: host and
7+
// user-agent headers are caller-controlled and must never grant auth.
8+
9+
import { NextRequest } from 'next/server';
10+
11+
export function hasValidCronSecret(request: NextRequest): boolean {
12+
const secret = process.env.CRON_SECRET;
13+
if (!secret) return false;
14+
return request.headers.get('authorization') === `Bearer ${secret}`;
15+
}

0 commit comments

Comments
 (0)