Skip to content

Commit a5ab86b

Browse files
Merge pull request #523 from CodeForAfrica/code-review-fixes
fix(security): restrict access to settings and tenant fields
2 parents 5520fd9 + 0047b54 commit a5ab86b

File tree

14 files changed

+226
-76
lines changed

14 files changed

+226
-76
lines changed

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,9 @@ SMTP_FROM_NAME=
3131
# Payload jobs cron schedule
3232
PAYLOAD_JOBS_CRON_SCHEDULE="* * * * *"
3333
PAYLOAD_JOBS_QUEUE=everyMinute
34+
35+
# Meedan Image Hosts (Optional)
36+
MEEDAN_ALLOWED_IMAGE_HOSTS=
37+
38+
# Max Download Size in Bytes (Optional)
39+
MAX_DOWNLOAD_BYTES=104857600

next.config.mjs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ import { withPayload } from "@payloadcms/next/withPayload";
55
const nextConfig = {
66
// Your Next.js config here
77
eslint: {
8-
// Warning: This allows production builds to successfully complete even if
9-
// your project has ESLint errors.
10-
ignoreDuringBuilds: true,
8+
ignoreDuringBuilds: false,
119
},
1210
output: "standalone",
1311
webpack: (webpackConfig) => {

src/app/(frontend)/[entitySlug]/promises/[promiseId]/page.tsx

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,12 @@ const prefillAirtableForm = (
4545
embedCode: string,
4646
promiseTitle?: string,
4747
promiseUrl?: string,
48-
updateDate?: string | Date
48+
updateDate?: string | Date,
4949
) => {
5050
const hasAny = Boolean(
5151
(promiseTitle && promiseTitle.trim()) ||
5252
(promiseUrl && promiseUrl.trim()) ||
53-
(updateDate && String(updateDate).trim())
53+
(updateDate && String(updateDate).trim()),
5454
);
5555

5656
if (!hasAny) {
@@ -84,24 +84,24 @@ const prefillAirtableForm = (
8484
if (promiseTitle && promiseTitle.trim()) {
8585
params.push(
8686
`${encodeURIComponent("prefill_Promise")}=${encodeURIComponent(
87-
promiseTitle.trim()
88-
)}`
87+
promiseTitle.trim(),
88+
)}`,
8989
);
9090
}
9191
if (promiseUrl && promiseUrl.trim()) {
9292
params.push(
9393
`${encodeURIComponent("prefill_CheckMedia Link")}=${encodeURIComponent(
94-
promiseUrl.trim()
95-
)}`
94+
promiseUrl.trim(),
95+
)}`,
9696
);
9797
params.push(
98-
`${encodeURIComponent("hide_CheckMedia Link")}=${encodeURIComponent("true")}`
98+
`${encodeURIComponent("hide_CheckMedia Link")}=${encodeURIComponent("true")}`,
9999
);
100100
}
101101
const formattedDate = formatDate(updateDate);
102102
if (formattedDate) {
103103
params.push(
104-
`${encodeURIComponent("prefill_Date")}=${encodeURIComponent(formattedDate)}`
104+
`${encodeURIComponent("prefill_Date")}=${encodeURIComponent(formattedDate)}`,
105105
);
106106
}
107107
if (!params.length) {
@@ -139,7 +139,7 @@ export async function generateMetadata({
139139
const politicalEntity = await getPoliticalEntityBySlug(
140140
tenant,
141141
entitySlug,
142-
tenantLocale
142+
tenantLocale,
143143
);
144144
if (!politicalEntity) {
145145
return buildSeoMetadata({
@@ -181,7 +181,7 @@ export async function generateMetadata({
181181
composeTitleSegments(
182182
promise.title?.trim() || tenantTitleBase || tenantSeo.title,
183183
politicalEntity.name,
184-
positionRegion
184+
positionRegion,
185185
) ??
186186
entitySeo.title ??
187187
tenantSeo.title;
@@ -209,7 +209,7 @@ const parseYear = (value?: string | null): number | null => {
209209

210210
const computeTimelineInterval = (
211211
entityPeriod: { from?: string | null; to?: string | null },
212-
statusHistory: { date: string }[]
212+
statusHistory: { date: string }[],
213213
): [number, number] => {
214214
const start = parseYear(entityPeriod.from);
215215
const end = parseYear(entityPeriod.to);
@@ -233,7 +233,7 @@ const computeTimelineInterval = (
233233
};
234234

235235
const buildStatusDocument = (
236-
promise: PromiseDocument
236+
promise: PromiseDocument,
237237
): PromiseStatusDocument | null => {
238238
const relation = promise.status;
239239
if (!relation) {
@@ -265,7 +265,7 @@ export default async function PromiseDetailPage({
265265

266266
const { title, description, navigation, footer } = await getTenantNavigation(
267267
tenant,
268-
locale
268+
locale,
269269
);
270270

271271
const entity = await getPoliticalEntityBySlug(tenant, entitySlug, locale);
@@ -317,14 +317,14 @@ export default async function PromiseDetailPage({
317317
rawPromiseUpdateEmbed,
318318
titleText,
319319
promiseUrl,
320-
statusDate
320+
statusDate,
321321
)
322322
: null;
323323

324324
const { actNow } = siteSettings || {};
325325
const timelineInterval = computeTimelineInterval(
326326
{ from: entity.periodFrom, to: entity.periodTo },
327-
timelineStatusHistory
327+
timelineStatusHistory,
328328
);
329329

330330
const promiseImage = await resolveMedia(promise.image ?? null);

src/app/api/meedan-sync/route.ts

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,24 @@ import * as Sentry from "@sentry/nextjs";
55

66
import { getGlobalPayload } from "@/lib/payload";
77
import { downloadFile } from "@/utils/files";
8-
import type {
9-
Promise as PayloadPromise,
10-
} from "@/payload-types";
8+
import type { Promise as PayloadPromise } from "@/payload-types";
119
import {
1210
buildMeedanIdCandidates,
1311
normaliseString,
1412
type MeedanWebhookPayload,
1513
} from "./utils";
1614

1715
const WEBHOOK_SECRET_ENV_KEY = "WEBHOOK_SECRET_KEY";
16+
const DEFAULT_MAX_IMAGE_BYTES =
17+
Number(process.env.MEEDAN_MAX_IMAGE_BYTES) || 10 * 1024 * 1024;
18+
const ALLOWED_IMAGE_MIME_TYPES = [
19+
"image/jpeg",
20+
"image/jpg",
21+
"image/png",
22+
"image/gif",
23+
"image/webp",
24+
"image/svg+xml",
25+
];
1826

1927
type WithOptionalId = {
2028
id?: unknown;
@@ -63,7 +71,7 @@ export const POST = async (request: NextRequest) => {
6371
Sentry.captureMessage(message, "error");
6472
return NextResponse.json(
6573
{ ok: false, updated: false, error: "Service misconfigured" },
66-
{ status: 200 }
74+
{ status: 500 },
6775
);
6876
}
6977

@@ -84,10 +92,6 @@ export const POST = async (request: NextRequest) => {
8492
Sentry.captureMessage(message, "error");
8593
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
8694
}
87-
Sentry.captureMessage(
88-
`meedan-sync:: Received webhook payload ${JSON.stringify(parsed)}`,
89-
"info"
90-
);
9195
const annotationType = parsed?.object?.annotation_type?.trim();
9296
if (annotationType && annotationType !== "report_design") {
9397
return NextResponse.json({ ok: true, skipped: true }, { status: 200 });
@@ -103,7 +107,7 @@ export const POST = async (request: NextRequest) => {
103107
{
104108
error: "Missing Meedan ID",
105109
},
106-
{ status: 400 }
110+
{ status: 400 },
107111
);
108112
}
109113

@@ -119,10 +123,19 @@ export const POST = async (request: NextRequest) => {
119123
const url = normaliseString(options?.published_article_url);
120124
const headline = normaliseString(options?.headline);
121125
const fieldValue = normaliseString(
122-
annotationData?.fields?.[0]?.value ?? null
126+
annotationData?.fields?.[0]?.value ?? null,
123127
);
124128
const imageUrl = normaliseString(parsed?.object?.file?.[0]?.url ?? null);
125129

130+
Sentry.withScope((scope) => {
131+
scope.setTag("route", "meedan-sync");
132+
scope.setContext("webhook", {
133+
annotationType,
134+
meedanId,
135+
});
136+
Sentry.captureMessage("meedan-sync:: Received webhook", "info");
137+
});
138+
126139
try {
127140
const payload = await getGlobalPayload();
128141

@@ -145,10 +158,7 @@ export const POST = async (request: NextRequest) => {
145158

146159
console.error(message);
147160
Sentry.captureMessage(message, "error");
148-
return NextResponse.json(
149-
{ error: "Promise not found" },
150-
{ status: 404 }
151-
);
161+
return NextResponse.json({ error: "Promise not found" }, { status: 404 });
152162
}
153163

154164
const updateData: Partial<PayloadPromise> = {};
@@ -176,13 +186,16 @@ export const POST = async (request: NextRequest) => {
176186
Sentry.captureMessage(message, "error");
177187
return NextResponse.json(
178188
{ error: "Failed to persist promise" },
179-
{ status: 500 }
189+
{ status: 500 },
180190
);
181191
}
182192

183193
if (!imageUrl) {
184194
return NextResponse.json({ ok: true, created, updated }, { status: 200 });
185195
}
196+
if (!imageUrl.startsWith("https://")) {
197+
return NextResponse.json({ error: "Invalid image URL" }, { status: 400 });
198+
}
186199

187200
const fallbackAlt =
188201
headline ??
@@ -196,7 +209,10 @@ export const POST = async (request: NextRequest) => {
196209
let filePath: string | null = null;
197210

198211
try {
199-
filePath = await downloadFile(imageUrl);
212+
filePath = await downloadFile(imageUrl, {
213+
allowedMimeTypes: ALLOWED_IMAGE_MIME_TYPES,
214+
maxBytes: DEFAULT_MAX_IMAGE_BYTES,
215+
});
200216

201217
const existingImageId = getRelationId(promise.image);
202218
let mediaId = existingImageId;
@@ -233,12 +249,12 @@ export const POST = async (request: NextRequest) => {
233249
});
234250
Sentry.captureMessage(
235251
"meedan-sync:: Failed to cache image after processing webhook",
236-
"error"
252+
"error",
237253
);
238254
});
239255
return NextResponse.json(
240256
{ error: "Failed to cache image" },
241-
{ status: 500 }
257+
{ status: 500 },
242258
);
243259
}
244260

@@ -251,18 +267,15 @@ export const POST = async (request: NextRequest) => {
251267
});
252268

253269
updated = true;
254-
return NextResponse.json(
255-
{ ok: true, created, updated },
256-
{ status: 200 }
257-
);
270+
return NextResponse.json({ ok: true, created, updated }, { status: 200 });
258271
} finally {
259272
if (filePath) {
260273
try {
261274
await unlink(filePath);
262275
} catch (cleanupError) {
263276
console.warn(
264277
"meedan-sync:: Failed to clean up temp image",
265-
cleanupError
278+
cleanupError,
266279
);
267280

268281
Sentry.withScope((scope) => {
@@ -289,7 +302,7 @@ export const POST = async (request: NextRequest) => {
289302
});
290303
return NextResponse.json(
291304
{ ok: false, updated: false, error: "Failed to process webhook" },
292-
{ status: 200 }
305+
{ status: 500 },
293306
);
294307
}
295308
};

0 commit comments

Comments
 (0)