Skip to content

Commit 84ee30e

Browse files
committed
fix: harden security and fix small bugs
1 parent 60bf6f8 commit 84ee30e

25 files changed

Lines changed: 399 additions & 87 deletions

.dockerignore

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
.git
2+
.env
3+
.env.*
4+
!.env.example
5+
node_modules
6+
.next
7+
data
8+
data-archive
9+
db
10+
research
11+
obelisk_backup
12+
.claude
13+
.github
14+
.mypy_cache

.env.example

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
# Obelisk — copy to .env and fill in your values.
2-
# Required: NEXT_PUBLIC_MAPBOX_TOKEN. Everything else has dev defaults.
2+
# Required: NEXT_PUBLIC_MAPBOX_TOKEN, MAPBOX_SERVER_TOKEN. Everything else has dev defaults.
33

44
# ─── Mapbox ───
55
NEXT_PUBLIC_MAPBOX_TOKEN=pk.your_token_here
66

7-
# Server-side Mapbox token (optional — restricts geocoding to a scoped secret token)
8-
# If not set, falls back to NEXT_PUBLIC_MAPBOX_TOKEN
7+
# Server-side Mapbox token (optional — falls back to NEXT_PUBLIC_MAPBOX_TOKEN)
98
# MAPBOX_SERVER_TOKEN=sk.your_server_token_here
109

1110
# Custom Mapbox styles (optional — defaults to Mapbox streets/dark if not set)
@@ -31,6 +30,17 @@ POSTGRES_PASSWORD=obelisk_dev
3130
# ─── Search ───
3231
TYPESENSE_API_KEY=obelisk_typesense_dev
3332

33+
# ─── Proxy ───
34+
# Set to "cloudflare", "nginx", or "proxy" if behind a reverse proxy.
35+
# Controls which forwarded headers are trusted for rate limiting.
36+
# TRUST_PROXY=
37+
38+
# ─── Distributed Enrichment ───
39+
# Shared secret for coordinator <-> worker auth (auto-generated if not set)
40+
# COORDINATOR_SECRET=
41+
# Coordinator bind address (default 127.0.0.1 — set to 0.0.0.0 for LAN access)
42+
# COORDINATOR_HOST=127.0.0.1
43+
3444
# ─── Seeding ───
3545
# Radius in meters from city center (-1 = all POIs in PBF extract)
3646
SEED_RADIUS=-1

.github/workflows/dependabot-lockfile.yml

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,26 @@ jobs:
1313
if: github.actor == 'dependabot[bot]'
1414
runs-on: ubuntu-latest
1515
steps:
16+
# Checkout base branch (NOT the PR head) to avoid executing untrusted code
1617
- uses: actions/checkout@v6
17-
with:
18-
ref: ${{ github.head_ref }}
1918
- uses: oven-sh/setup-bun@v2
19+
- name: Fetch PR package.json
20+
env:
21+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
22+
run: |
23+
gh api "repos/${{ github.repository }}/contents/package.json?ref=${{ github.event.pull_request.head.sha }}" \
24+
--jq '.content' | base64 -d > package.json
2025
- run: bun install
2126
- name: Commit updated lockfile
2227
env:
2328
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2429
REPO: ${{ github.repository }}
25-
BRANCH: ${{ github.head_ref }}
30+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
2631
run: |
2732
if git diff --quiet bun.lock; then
2833
echo "bun.lock unchanged, skipping"
2934
exit 0
3035
fi
31-
HEAD_SHA=$(gh api "repos/${REPO}/git/refs/heads/${BRANCH}" --jq '.object.sha')
3236
BASE_TREE=$(gh api "repos/${REPO}/git/commits/${HEAD_SHA}" --jq '.tree.sha')
3337
BLOB=$(base64 -w0 bun.lock | jq -Rs '{"content": ., "encoding": "base64"}' | \
3438
gh api "repos/${REPO}/git/blobs" --input - --jq '.sha')
@@ -38,4 +42,4 @@ jobs:
3842
COMMIT=$(jq -n --arg t "$TREE" --arg p "$HEAD_SHA" --arg m "chore: update bun.lock" \
3943
'{message: $m, tree: $t, parents: [$p]}' | \
4044
gh api "repos/${REPO}/git/commits" --input - --jq '.sha')
41-
gh api "repos/${REPO}/git/refs/heads/${BRANCH}" -X PATCH -f sha="$COMMIT"
45+
gh api "repos/${REPO}/git/refs/heads/${{ github.head_ref }}" -X PATCH -f sha="$COMMIT"

.gitignore

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ yarn-error.log*
2929
.pnpm-debug.log*
3030

3131
# env files
32-
.env
33-
.env.local
32+
.env*
33+
!.env.example
3434

3535
# vercel
3636
.vercel
@@ -57,9 +57,6 @@ drizzle/*.sql
5757
# Removed services (Docker-owned leftovers)
5858
/searxng/
5959

60-
# Env
61-
!.env.example
62-
6360
# Backup (internal docs, not for public repo)
6461
/obelisk_backup/
6562

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ COPY . .
99
RUN mkdir -p .next data
1010

1111
EXPOSE 3000
12+
CMD ["bun", "run", "dev"]

Makefile

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: help setup setup-quick finish-setup run run-local stop logs rebuild destroy download-pbf download-datasets build-taxonomy build-brands seed-regions seed-cuisines seed-tags seed-pois seed-all seed-city enrich-taxonomy-only enrich-pois enrich-distributed enrich-worker fetch-wikipedia fetch-websites fetch-mapillary sync-search generate-embeddings search-setup db-dump db-restore
1+
.PHONY: help setup setup-quick finish-setup run stop logs rebuild destroy download-pbf download-datasets build-taxonomy build-brands seed-regions seed-cuisines seed-tags seed-pois seed-all seed-city enrich-taxonomy-only enrich-pois enrich-distributed enrich-worker fetch-wikipedia fetch-websites fetch-mapillary sync-search generate-embeddings search-setup db-dump db-restore
22
CYAN := \033[36m
33
GREEN := \033[32m
44
YELLOW := \033[33m
@@ -33,8 +33,7 @@ help:
3333
@printf " $(CYAN)setup$(RESET) First-time setup (seed + taxonomy + search). Resume: make setup FROM=6\n"
3434
@printf " $(CYAN)setup-quick$(RESET) Quick setup from db/dump.sql (skip seed + enrich)\n"
3535
@printf " $(CYAN)finish-setup$(RESET) Sync search index + generate embeddings\n"
36-
@printf " $(CYAN)run$(RESET) Start on localhost:3000\n"
37-
@printf " $(CYAN)run-local$(RESET) Start exposed to local network (same WiFi)\n"
36+
@printf " $(CYAN)run$(RESET) Start on localhost:3000 (also accessible on local network)\n"
3837

3938
@printf " $(CYAN)stop$(RESET) Stop services (keeps data)\n"
4039
@printf " $(CYAN)logs$(RESET) View database logs\n"
@@ -185,18 +184,9 @@ setup-quick:
185184
@printf "$(GREEN)Quick setup complete!$(RESET) Run 'make run' to start\n"
186185

187186
run:
188-
@printf "$(GREEN)Starting Obelisk...$(RESET)\n"
189-
@printf "\n"
190-
@printf "$(GREEN)App starting at http://localhost:3000$(RESET)\n"
191-
@printf "Press Ctrl+C to stop\n"
192-
@printf "\n"
193-
$(COMPOSE) up
194-
195-
run-local:
196-
@printf "$(GREEN)Starting Obelisk for local network...$(RESET)\n"
197187
@LOCAL_IP=$$(hostname -I | awk '{print $$1}'); \
188+
printf "$(GREEN)Starting Obelisk...$(RESET)\n"; \
198189
printf "\n"; \
199-
printf "$(GREEN)App starting:$(RESET)\n"; \
200190
printf " Local: http://localhost:3000\n"; \
201191
printf " Network: http://$$LOCAL_IP:3000\n"; \
202192
printf "\n"; \
@@ -280,7 +270,7 @@ enrich-distributed:
280270
@printf "$(GREEN)Ollama healthy$(RESET)\n"
281271
@printf "\n"
282272
@LOCAL_IP=$$(hostname -I | awk '{print $$1}'); \
283-
PG_PASS=$$(grep -oP 'POSTGRES_PASSWORD=\K.*' .env 2>/dev/null || echo 'obelisk_dev'); \
273+
COORD_SECRET=$$(grep -oP 'COORDINATOR_SECRET=\K.*' .env 2>/dev/null || echo ''); \
284274
printf "$(CYAN)[2/4]$(RESET) Printing worker instructions...\n"; \
285275
printf "\n"; \
286276
printf "═══════════════════════════════════════════════════\n"; \
@@ -297,15 +287,17 @@ enrich-distributed:
297287
printf " 3. Pull the Ollama model:\n"; \
298288
printf " ollama pull $(OLLAMA_MODEL)\n"; \
299289
printf "\n"; \
300-
printf " 4. Start enrichment:\n"; \
301-
printf " DATABASE_URL=\"postgresql://obelisk:$$PG_PASS@$$LOCAL_IP:5432/obelisk\" \\\\\n"; \
290+
printf " 4. Copy DATABASE_URL and COORDINATOR_SECRET from coordinator .env\n"; \
291+
printf " Then start enrichment:\n"; \
292+
printf " DATABASE_URL=\"<from coordinator .env>\" \\\\\n"; \
302293
printf " ENRICH_COORDINATOR_URL=\"http://$$LOCAL_IP:3939\" \\\\\n"; \
294+
printf " COORDINATOR_SECRET=\"<from coordinator .env>\" \\\\\n"; \
303295
printf " make enrich-worker\n"; \
304296
printf "\n"; \
305297
printf " Monitor: curl http://$$LOCAL_IP:3939/status | jq\n"; \
306298
printf "═══════════════════════════════════════════════════\n"; \
307299
printf "\n"
308-
@printf "$(CYAN)[3/5]$(RESET) Tuning Ollama for parallel inference...\n"
300+
@printf "$(CYAN)[3/4]$(RESET) Tuning Ollama for parallel inference...\n"
309301
@CURRENT=$$(curl -sf $(OLLAMA_URL)/api/ps 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('models',[{}])[0].get('context_length',0))" 2>/dev/null || echo 0); \
310302
VRAM_FREE=$$(nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits 2>/dev/null | head -1 || echo 0); \
311303
NUM_PAR=$${OLLAMA_NUM_PARALLEL:-4}; \
@@ -321,12 +313,11 @@ enrich-distributed:
321313
printf " OLLAMA_NUM_PARALLEL=$$NUM_PAR ollama serve\n"; \
322314
fi; \
323315
printf "\n"
324-
@printf "$(CYAN)[4/5]$(RESET) Starting coordinator...\n"
316+
@printf "$(CYAN)[4/4]$(RESET) Starting coordinator + local worker...\n"
325317
@$(COMPOSE) exec -d -T app bun scripts/enrich-coordinator.ts
326318
@sleep 2
327319
@printf "$(GREEN)Coordinator running on :3939$(RESET)\n"
328320
@printf "\n"
329-
@printf "$(CYAN)[5/5]$(RESET) Starting local enrichment worker...\n"
330321
$(COMPOSE) exec -e ENRICH_COORDINATOR_URL=http://localhost:3939 -e ENRICH_CONCURRENCY=6 app bun scripts/enrich-pois.ts
331322

332323
enrich-worker:

docker-compose.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ services:
2828
POSTGRES_USER: ${POSTGRES_USER:-obelisk}
2929
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
3030
ports:
31-
- "5432:5432"
31+
- "127.0.0.1:5432:5432"
3232
volumes:
3333
- postgres_data:/var/lib/postgresql/data
3434
healthcheck:
@@ -40,7 +40,7 @@ services:
4040
typesense:
4141
image: typesense/typesense:30.1
4242
ports:
43-
- "8108:8108"
43+
- "127.0.0.1:8108:8108"
4444
volumes:
4545
- typesense_data:/data
4646
environment:

drizzle.config.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ export default defineConfig({
55
out: "./drizzle",
66
dialect: "postgresql",
77
dbCredentials: {
8-
url: process.env.DATABASE_URL || "postgresql://obelisk:obelisk_dev@localhost:5432/obelisk",
8+
url: (() => {
9+
if (!process.env.DATABASE_URL) {
10+
throw new Error("DATABASE_URL is required. Copy .env.example to .env and configure it.");
11+
}
12+
return process.env.DATABASE_URL;
13+
})(),
914
},
1015
tablesFilter: [
1116
"regions",

next.config.ts

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,59 @@
11
import type { NextConfig } from "next";
2+
import { networkInterfaces } from "os";
3+
4+
const isProd = process.env.NODE_ENV === "production";
5+
6+
/**
7+
* Collects non-loopback IPv4 addresses from all network interfaces
8+
* so Next.js allows dev access from the local network.
9+
*
10+
* @returns Array of local IPv4 address strings.
11+
*/
12+
function getLocalIps(): string[] {
13+
const ips: string[] = [];
14+
for (const addrs of Object.values(networkInterfaces())) {
15+
if (!addrs) continue;
16+
for (const addr of addrs) {
17+
if (addr.family === "IPv4" && !addr.internal) ips.push(addr.address);
18+
}
19+
}
20+
return ips;
21+
}
22+
23+
const securityHeaders = [
24+
{ key: "X-Content-Type-Options", value: "nosniff" },
25+
{ key: "X-Frame-Options", value: "DENY" },
26+
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
27+
{ key: "X-DNS-Prefetch-Control", value: "on" },
28+
{
29+
key: "Permissions-Policy",
30+
value: "camera=(), microphone=(), geolocation=(self)",
31+
},
32+
{
33+
key: "Content-Security-Policy",
34+
value: [
35+
"default-src 'self'",
36+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
37+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
38+
"font-src 'self' https://fonts.gstatic.com",
39+
"img-src 'self' data: blob: https://*.mapbox.com https://*.mapillary.com https://*.fbcdn.net https://*.wikipedia.org https://*.wikimedia.org",
40+
"connect-src 'self' https://*.mapbox.com https://*.mapillary.com https://*.fbcdn.net https://nominatim.openstreetmap.org",
41+
"worker-src 'self' blob:",
42+
"frame-src 'none'",
43+
].join("; "),
44+
},
45+
];
246

347
const nextConfig: NextConfig = {
48+
allowedDevOrigins: isProd ? [] : getLocalIps(),
449
experimental: {
550
serverActions: {
651
bodySizeLimit: "2mb",
752
},
853
},
954
async headers() {
10-
return [
11-
{
12-
source: "/(.*)",
13-
headers: [
14-
{ key: "X-Content-Type-Options", value: "nosniff" },
15-
{ key: "X-Frame-Options", value: "DENY" },
16-
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
17-
{ key: "X-DNS-Prefetch-Control", value: "on" },
18-
{
19-
key: "Permissions-Policy",
20-
value: "camera=(), microphone=(), geolocation=(self)",
21-
},
22-
{
23-
key: "Content-Security-Policy",
24-
value: [
25-
"default-src 'self'",
26-
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
27-
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
28-
"font-src 'self' https://fonts.gstatic.com",
29-
"img-src 'self' data: blob: https://*.mapbox.com https://*.mapillary.com https://*.fbcdn.net https://*.wikipedia.org https://*.wikimedia.org",
30-
"connect-src 'self' https://*.mapbox.com https://*.mapillary.com https://*.fbcdn.net https://nominatim.openstreetmap.org",
31-
"worker-src 'self' blob:",
32-
"frame-src 'none'",
33-
].join("; "),
34-
},
35-
],
36-
},
37-
];
55+
if (!isProd) return [];
56+
return [{ source: "/(.*)", headers: securityHeaders }];
3857
},
3958
};
4059

scripts/enrich-coordinator.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,24 @@ import { createLogger } from "../src/lib/logger";
66
const log = createLogger("enrich-coordinator");
77

88
const PORT = parseInt(process.env.COORDINATOR_PORT || "3939", 10);
9+
const HOST = process.env.COORDINATOR_HOST || "127.0.0.1";
10+
const COORDINATOR_SECRET = process.env.COORDINATOR_SECRET || crypto.randomUUID();
911
const BATCH_SIZE = parseInt(process.env.ENRICH_BATCH_SIZE || "10", 10);
1012
const BATCH_TIMEOUT_MS = 5 * 60 * 1000;
1113
const RECOVERY_INTERVAL_MS = 60 * 1000;
1214
const FORCE = process.argv.includes("--force");
1315

16+
/**
17+
* Validates the Authorization header against the coordinator secret.
18+
*
19+
* @param req - Incoming request.
20+
* @returns True if the request is authorized.
21+
*/
22+
function checkAuth(req: Request): boolean {
23+
const auth = req.headers.get("authorization");
24+
return auth === `Bearer ${COORDINATOR_SECRET}`;
25+
}
26+
1427
interface BatchInfo {
1528
poiIds: string[];
1629
workerId: string;
@@ -178,12 +191,17 @@ async function main(): Promise<void> {
178191
}
179192

180193
Bun.serve({
194+
hostname: HOST,
181195
port: PORT,
182196
async fetch(req: Request): Promise<Response> {
183197
const url = new URL(req.url);
184198
const path = url.pathname;
185199
const method = req.method;
186200

201+
if (path !== "/status" && !checkAuth(req)) {
202+
return Response.json({ error: "unauthorized" }, { status: 401 });
203+
}
204+
187205
if (method === "POST" && path === "/register") {
188206
const body = await req.json() as { name?: string };
189207
const workerId = `w${nextWorkerId++}`;
@@ -245,7 +263,12 @@ async function main(): Promise<void> {
245263
},
246264
});
247265

248-
log.info(`Coordinator listening on http://0.0.0.0:${PORT}`);
266+
log.info(`Coordinator listening on http://${HOST}:${PORT}`);
267+
if (!process.env.COORDINATOR_SECRET) {
268+
log.info(`Auto-generated coordinator secret: ${COORDINATOR_SECRET}`);
269+
} else {
270+
log.info("Using COORDINATOR_SECRET from environment");
271+
}
249272

250273
setInterval(recoverTimedOutBatches, RECOVERY_INTERVAL_MS);
251274

0 commit comments

Comments
 (0)