Skip to content

Commit 4a0936d

Browse files
committed
Harden external traffic and prevent private-site leakage in shared simulations
1 parent d7e9cad commit 4a0936d

14 files changed

Lines changed: 443 additions & 38 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ Independent web reimplementation inspired by Radio Mobile workflows.
1414
4. Cache archives in browser Cache Storage.
1515
5. Parse and use loaded SRTM tiles in propagation/profile/terrain overlay.
1616

17+
## External Service Safeguards
18+
19+
- Geocoding requests prefer `/api/geocode` (with per-IP rate limits + short-lived edge caching when running with Functions).
20+
- Geocode calls gracefully fall back to direct Nominatim in local runtimes without Functions.
21+
- Upstream proxy routes (`/meshmap/*`, `/ve2dbe/*`) are limited to `GET/HEAD` and include per-IP request caps in Functions.
22+
- Fallback map raster tiles use CARTO CDN attribution endpoints rather than direct OSM tile hosts.
23+
1724
## Runtime Proxy
1825

1926
Vite proxy is used for browser CORS compatibility in dev/preview:

functions/_lib/db.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,23 @@ type ActorPolicy = {
10491049
isModerator: boolean;
10501050
};
10511051

1052+
const referencedLibrarySiteIdsFromSimulation = (item: CloudResourceRecord): string[] => {
1053+
const snapshot = (item as { snapshot?: unknown }).snapshot;
1054+
if (!snapshot || typeof snapshot !== "object") return [];
1055+
const rawSites = (snapshot as { sites?: unknown }).sites;
1056+
if (!Array.isArray(rawSites)) return [];
1057+
const ids = new Set<string>();
1058+
for (const site of rawSites) {
1059+
if (!site || typeof site !== "object") continue;
1060+
const libraryEntryId = (site as { libraryEntryId?: unknown }).libraryEntryId;
1061+
if (typeof libraryEntryId !== "string") continue;
1062+
const trimmed = libraryEntryId.trim();
1063+
if (!trimmed) continue;
1064+
ids.add(trimmed);
1065+
}
1066+
return [...ids];
1067+
};
1068+
10521069
const canReadResource = (
10531070
actor: ActorPolicy,
10541071
ownerUserId: string,
@@ -1113,6 +1130,25 @@ const upsertOwnedResource = async (
11131130

11141131
const ownerId = existing?.owner_user_id ?? actor.id;
11151132

1133+
if (kind === "simulation" && visibility !== "private") {
1134+
const referencedSiteIds = referencedLibrarySiteIdsFromSimulation(item);
1135+
if (referencedSiteIds.length) {
1136+
const checks = await env.DB.batch(
1137+
referencedSiteIds.map((siteId) =>
1138+
env.DB.prepare("SELECT id, visibility FROM sites WHERE id = ?").bind(siteId),
1139+
),
1140+
);
1141+
const hasPrivateReference = checks.some((check) => {
1142+
const row = check.results[0] as { visibility?: unknown } | undefined;
1143+
if (!row) return false;
1144+
return visibilityFromDbVisibility(row.visibility) === "private";
1145+
});
1146+
if (hasPrivateReference) {
1147+
return { ok: false, reason: "simulation_private_site_reference" };
1148+
}
1149+
}
1150+
}
1151+
11161152
const isCreate = !existing;
11171153
const changed =
11181154
isCreate ||

functions/_lib/rateLimit.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
type RateLimitBucket = {
2+
windowStartMs: number;
3+
count: number;
4+
};
5+
6+
type RateLimitInput = {
7+
key: string;
8+
limit: number;
9+
windowMs?: number;
10+
nowMs?: number;
11+
};
12+
13+
type RateLimitResult = {
14+
allowed: boolean;
15+
remaining: number;
16+
retryAfterSec: number;
17+
};
18+
19+
const buckets = new Map<string, RateLimitBucket>();
20+
let callsSinceSweep = 0;
21+
22+
const DEFAULT_WINDOW_MS = 60_000;
23+
24+
const sweepExpiredBuckets = (nowMs: number) => {
25+
for (const [key, bucket] of buckets.entries()) {
26+
if (bucket.windowStartMs + DEFAULT_WINDOW_MS * 4 < nowMs) buckets.delete(key);
27+
}
28+
};
29+
30+
const toSafeLimit = (value: number): number => {
31+
if (!Number.isFinite(value)) return 1;
32+
return Math.max(1, Math.floor(value));
33+
};
34+
35+
export const getClientAddress = (request: Request): string => {
36+
const cfIp = (request.headers.get("cf-connecting-ip") ?? "").trim();
37+
if (cfIp) return cfIp;
38+
const forwarded = (request.headers.get("x-forwarded-for") ?? "").split(",")[0]?.trim();
39+
if (forwarded) return forwarded;
40+
return "unknown";
41+
};
42+
43+
export const takeRateLimitToken = ({
44+
key,
45+
limit,
46+
windowMs = DEFAULT_WINDOW_MS,
47+
nowMs = Date.now(),
48+
}: RateLimitInput): RateLimitResult => {
49+
const safeLimit = toSafeLimit(limit);
50+
const safeWindowMs = Math.max(1_000, Math.floor(windowMs));
51+
52+
callsSinceSweep += 1;
53+
if (callsSinceSweep >= 100) {
54+
callsSinceSweep = 0;
55+
sweepExpiredBuckets(nowMs);
56+
}
57+
58+
const existing = buckets.get(key);
59+
if (!existing || nowMs - existing.windowStartMs >= safeWindowMs) {
60+
buckets.set(key, { windowStartMs: nowMs, count: 1 });
61+
return {
62+
allowed: true,
63+
remaining: Math.max(0, safeLimit - 1),
64+
retryAfterSec: 0,
65+
};
66+
}
67+
68+
if (existing.count >= safeLimit) {
69+
const retryMs = Math.max(0, safeWindowMs - (nowMs - existing.windowStartMs));
70+
return {
71+
allowed: false,
72+
remaining: 0,
73+
retryAfterSec: Math.max(1, Math.ceil(retryMs / 1000)),
74+
};
75+
}
76+
77+
existing.count += 1;
78+
buckets.set(key, existing);
79+
return {
80+
allowed: true,
81+
remaining: Math.max(0, safeLimit - existing.count),
82+
retryAfterSec: 0,
83+
};
84+
};

functions/_lib/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export type Env = {
3232
DEV_AUTH_USER_ID?: string;
3333
ADMIN_USER_IDS?: string;
3434
REGISTRATION_MODE?: string;
35+
GEOCODE_RATE_LIMIT_PER_MINUTE?: string;
36+
PROXY_RATE_LIMIT_PER_MINUTE?: string;
3537
};
3638

3739
export type AuthContext = {

functions/api/geocode.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { getClientAddress, takeRateLimitToken } from "../_lib/rateLimit";
2+
import { errorResponse, handleOptions, json, withCors } from "../_lib/http";
3+
import type { Env } from "../_lib/types";
4+
5+
type NominatimResult = {
6+
place_id: number;
7+
display_name: string;
8+
lat: string;
9+
lon: string;
10+
};
11+
12+
const parsePerMinuteLimit = (raw: string | undefined, fallback: number): number => {
13+
const parsed = Number(raw ?? "");
14+
if (!Number.isFinite(parsed)) return fallback;
15+
return Math.max(1, Math.floor(parsed));
16+
};
17+
18+
const CACHE_TTL_SEC = 300;
19+
20+
export const onRequestOptions: PagesFunction<Env> = async ({ request }) => handleOptions(request);
21+
22+
export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
23+
try {
24+
const url = new URL(request.url);
25+
const query = (url.searchParams.get("q") ?? "").trim();
26+
27+
if (!query) return withCors(request, json({ results: [] }));
28+
if (query.length < 3) {
29+
return withCors(request, json({ error: "Search query must be at least 3 characters." }, { status: 400 }));
30+
}
31+
32+
const limitPerMinute = parsePerMinuteLimit(env.GEOCODE_RATE_LIMIT_PER_MINUTE, 20);
33+
const address = getClientAddress(request);
34+
const limiter = takeRateLimitToken({ key: `geocode:${address}`, limit: limitPerMinute });
35+
if (!limiter.allowed) {
36+
return withCors(
37+
request,
38+
json(
39+
{ error: "Geocode rate limit reached. Please wait and try again." },
40+
{
41+
status: 429,
42+
headers: {
43+
"retry-after": String(limiter.retryAfterSec),
44+
},
45+
},
46+
),
47+
);
48+
}
49+
50+
const normalized = query.toLowerCase();
51+
const cacheUrl = new URL(request.url);
52+
cacheUrl.search = "";
53+
cacheUrl.searchParams.set("q", normalized);
54+
const cacheKey = new Request(cacheUrl.toString(), { method: "GET" });
55+
const cache = caches.default;
56+
const cached = await cache.match(cacheKey);
57+
if (cached) return withCors(request, cached);
58+
59+
const upstream = new URL("https://nominatim.openstreetmap.org/search");
60+
upstream.searchParams.set("q", query);
61+
upstream.searchParams.set("format", "jsonv2");
62+
upstream.searchParams.set("limit", "6");
63+
upstream.searchParams.set("addressdetails", "0");
64+
65+
const response = await fetch(upstream.toString(), {
66+
headers: {
67+
accept: "application/json",
68+
},
69+
});
70+
if (!response.ok) {
71+
throw new Error(`Geocode lookup failed (${response.status})`);
72+
}
73+
74+
const payload = (await response.json()) as NominatimResult[];
75+
const results = payload
76+
.map((item) => ({
77+
id: String(item.place_id),
78+
label: item.display_name,
79+
lat: Number(item.lat),
80+
lon: Number(item.lon),
81+
}))
82+
.filter((item) => Number.isFinite(item.lat) && Number.isFinite(item.lon));
83+
84+
const apiResponse = json(
85+
{ results },
86+
{
87+
headers: {
88+
"cache-control": `public, max-age=${CACHE_TTL_SEC}`,
89+
},
90+
},
91+
);
92+
await cache.put(cacheKey, apiResponse.clone());
93+
return withCors(request, apiResponse);
94+
} catch (error) {
95+
return errorResponse(request, error, 500);
96+
}
97+
};

functions/meshmap/[[path]].ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,43 @@
11
import { handleOptions } from "../_lib/http";
2+
import { getClientAddress, takeRateLimitToken } from "../_lib/rateLimit";
23
import type { Env } from "../_lib/types";
34

45
export const onRequestOptions: PagesFunction<Env> = async ({ request }) => handleOptions(request);
56

6-
export const onRequest: PagesFunction<Env> = async ({ request }) => {
7+
const parsePerMinuteLimit = (raw: string | undefined, fallback: number): number => {
8+
const parsed = Number(raw ?? "");
9+
if (!Number.isFinite(parsed)) return fallback;
10+
return Math.max(1, Math.floor(parsed));
11+
};
12+
13+
export const onRequest: PagesFunction<Env> = async ({ request, env }) => {
14+
if (request.method !== "GET" && request.method !== "HEAD") {
15+
return new Response("Method not allowed", { status: 405 });
16+
}
17+
18+
const ip = getClientAddress(request);
19+
const limiter = takeRateLimitToken({
20+
key: `proxy:meshmap:${ip}`,
21+
limit: parsePerMinuteLimit(env.PROXY_RATE_LIMIT_PER_MINUTE, 120),
22+
});
23+
if (!limiter.allowed) {
24+
return new Response("Rate limit reached", {
25+
status: 429,
26+
headers: {
27+
"retry-after": String(limiter.retryAfterSec),
28+
},
29+
});
30+
}
31+
732
const url = new URL(request.url);
833
const upstreamPath = url.pathname.replace(/^\/meshmap/, "");
934
const upstream = new URL(`https://meshmap.net${upstreamPath}${url.search}`);
1035

1136
const response = await fetch(upstream.toString(), {
1237
method: request.method,
13-
headers: request.headers,
14-
body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
38+
headers: {
39+
accept: request.headers.get("accept") ?? "*/*",
40+
},
1541
});
1642

1743
return new Response(response.body, {

functions/ve2dbe/[[path]].ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,43 @@
11
import { handleOptions } from "../_lib/http";
2+
import { getClientAddress, takeRateLimitToken } from "../_lib/rateLimit";
23
import type { Env } from "../_lib/types";
34

45
export const onRequestOptions: PagesFunction<Env> = async ({ request }) => handleOptions(request);
56

6-
export const onRequest: PagesFunction<Env> = async ({ request }) => {
7+
const parsePerMinuteLimit = (raw: string | undefined, fallback: number): number => {
8+
const parsed = Number(raw ?? "");
9+
if (!Number.isFinite(parsed)) return fallback;
10+
return Math.max(1, Math.floor(parsed));
11+
};
12+
13+
export const onRequest: PagesFunction<Env> = async ({ request, env }) => {
14+
if (request.method !== "GET" && request.method !== "HEAD") {
15+
return new Response("Method not allowed", { status: 405 });
16+
}
17+
18+
const ip = getClientAddress(request);
19+
const limiter = takeRateLimitToken({
20+
key: `proxy:ve2dbe:${ip}`,
21+
limit: parsePerMinuteLimit(env.PROXY_RATE_LIMIT_PER_MINUTE, 120),
22+
});
23+
if (!limiter.allowed) {
24+
return new Response("Rate limit reached", {
25+
status: 429,
26+
headers: {
27+
"retry-after": String(limiter.retryAfterSec),
28+
},
29+
});
30+
}
31+
732
const url = new URL(request.url);
833
const upstreamPath = url.pathname.replace(/^\/ve2dbe/, "");
934
const upstream = new URL(`https://www.ve2dbe.com${upstreamPath}${url.search}`);
1035

1136
const response = await fetch(upstream.toString(), {
1237
method: request.method,
13-
headers: request.headers,
14-
body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
38+
headers: {
39+
accept: request.headers.get("accept") ?? "*/*",
40+
},
1541
});
1642

1743
return new Response(response.body, {

src/components/MapView.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,12 @@ const fallbackStyle = {
6363
osm: {
6464
type: "raster",
6565
tiles: [
66-
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
67-
"https://a.tile.openstreetmap.org/{z}/{x}/{y}.png",
66+
"https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",
67+
"https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",
6868
],
6969
tileSize: 256,
7070
attribution:
71-
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">© OpenStreetMap contributors</a>',
71+
'<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">© OpenStreetMap contributors</a> <a href="https://carto.com/attributions" target="_blank" rel="noreferrer">© CARTO</a>',
7272
},
7373
},
7474
layers: [

src/components/Sidebar.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,6 +1055,11 @@ export function Sidebar() {
10551055
setEditingLibraryStatus(`Ground elevation set from loaded terrain: ${elevation} m`);
10561056
};
10571057
const runLibrarySearch = async () => {
1058+
if (librarySearchQuery.trim().length < 3) {
1059+
setLibrarySearchResults([]);
1060+
setLibrarySearchStatus("Enter at least 3 characters to search.");
1061+
return;
1062+
}
10581063
setLibrarySearchStatus("Searching...");
10591064
try {
10601065
const results = await searchLocations(librarySearchQuery);

0 commit comments

Comments
 (0)