Skip to content

Commit 67f6348

Browse files
Security audit (#619)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 86d8b20 commit 67f6348

26 files changed

Lines changed: 668 additions & 31 deletions

File tree

.github/workflows/deploy-app.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ jobs:
5555
run: |
5656
cat <<'EOF' > .env.production
5757
NEXT_PUBLIC_RECAPTCHA_SITE_KEY=${{ secrets.RECAPTCHA_SITE_KEY }}
58+
NEXT_PUBLIC_RECAPTCHA_V3_SITE_KEY=${{ secrets.RECAPTCHA_V3_SITE_KEY }}
5859
NEXT_PUBLIC_MAPTILER_API_KEY=${{ secrets.NEXT_PUBLIC_MAPTILER_API_KEY }}
5960
NEXT_PUBLIC_S3_BUCKET_URL=https://tilesets1.cdn.districtr.org
6061
NEXT_PUBLIC_S3_BUCKET_URL_MIRROR1=https://tilesets2.cdn.districtr.org

app/.env.dev

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ NEXT_PUBLIC_S3_BUCKET_URL=https://tilesets1.cdn.districtr.org
33
NEXT_PUBLIC_S3_BUCKET_URL_MIRROR1=https://tilesets2.cdn.districtr.org
44
NEXT_PUBLIC_S3_BUCKET_URL_MIRROR2=https://tilesets3.cdn.districtr.org
55
NEXT_SERVER_API_URL=http://backend:8000
6+
NEXT_PUBLIC_RECAPTCHA_V3_SITE_KEY=

app/Dockerfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ ENV NODE_ENV="production"
1616
FROM base as build
1717
ARG NEXT_PUBLIC_API_URL
1818
ARG NEXT_PUBLIC_S3_BUCKET_URL
19+
ARG NEXT_PUBLIC_RECAPTCHA_V3_SITE_KEY
1920

2021
# Install packages needed to build node modules
2122
RUN apt-get update -qq && \
@@ -30,7 +31,8 @@ RUN bun install --include=dev
3031

3132
RUN echo NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL && \
3233
echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL" > .env.production && \
33-
echo "NEXT_PUBLIC_S3_BUCKET_URL=$NEXT_PUBLIC_S3_BUCKET_URL" >> .env.production
34+
echo "NEXT_PUBLIC_S3_BUCKET_URL=$NEXT_PUBLIC_S3_BUCKET_URL" >> .env.production && \
35+
echo "NEXT_PUBLIC_RECAPTCHA_V3_SITE_KEY=$NEXT_PUBLIC_RECAPTCHA_V3_SITE_KEY" >> .env.production
3436

3537
# Copy application code
3638
COPY --link . .

app/src/app/components/Topbar/MapActionsDropdown.tsx

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {ANONYMOUS_DOCUMENT_ID} from '@/app/constants/document/limits';
77
import {ACCESS_STATES} from '@constants/document/state';
88
import {DocumentMetadata} from '@utils/api/apiHandlers/types';
99
import {SaveShareModal} from '../Toolbar/SaveShareModal/SaveShareModal';
10+
import {fetchWithSession} from '@utils/api/session';
1011

1112
/** Consolidated "Map actions" menu for the editor topbar: share, export,
1213
* and reset in one dropdown. Saving lives in the topbar SaveButton;
@@ -18,6 +19,15 @@ export const MapActionsDropdown: React.FC<{
1819
const mapDocument = useMapStore(state => state.mapDocument);
1920
const access = useMapStore(state => state.mapStatus?.access);
2021
const handleReset = useMapStore(state => state.handleReset);
22+
const setNotification = useMapStore(state => state.setNotification);
23+
24+
const notifyExportFailed = (reason: string) =>
25+
setNotification({
26+
importance: 2,
27+
type: 'error',
28+
message: 'Exporting this map failed. Please try again in a moment.',
29+
id: `export-failed-${exportId}-${reason}`,
30+
});
2131

2232
// Defer past the dropdown's close so Radix doesn't leave pointer-events:none
2333
// stuck on the body when a dialog opens from onSelect.
@@ -31,15 +41,34 @@ export const MapActionsDropdown: React.FC<{
3141
? mapDocument.document_id
3242
: mapDocument?.public_id;
3343

34-
const downloadExport = (exportType: string) => {
44+
const downloadExport = async (exportType: string) => {
3545
if (!exportId) return;
36-
// Trigger via a transient anchor — a DropdownMenu.Item swallows a child anchor's
37-
// click. The download filename comes from the backend's Content-Disposition.
38-
const a = document.createElement('a');
39-
a.href = `${process.env.NEXT_PUBLIC_API_URL}/api/document/${exportId}/export?export_type=${exportType}`;
40-
document.body.appendChild(a);
41-
a.click();
42-
a.remove();
46+
// Fetch via the session-aware client (plain anchor navigation can't attach
47+
// the X-Districtr-Session header) and save the blob through a transient
48+
// anchor. Filename comes from the backend's Content-Disposition.
49+
try {
50+
const response = await fetchWithSession(
51+
`${process.env.NEXT_PUBLIC_API_URL}/api/document/${exportId}/export?export_type=${exportType}`
52+
);
53+
if (!response.ok) {
54+
notifyExportFailed(`${response.status}`);
55+
return;
56+
}
57+
const filename =
58+
response.headers.get('Content-Disposition')?.match(/filename="?([^";]+)"?/)?.[1] ??
59+
`districtr-export-${exportId}.${exportType.toLowerCase()}`;
60+
const url = URL.createObjectURL(await response.blob());
61+
const a = document.createElement('a');
62+
a.href = url;
63+
a.download = filename;
64+
document.body.appendChild(a);
65+
a.click();
66+
a.remove();
67+
URL.revokeObjectURL(url);
68+
} catch (e) {
69+
console.error('Export failed', e);
70+
notifyExportFailed('network');
71+
}
4372
};
4473

4574
return (

app/src/app/utils/api/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const PARQUET_URL =
1111
process.env.NEXT_PUBLIC_S3_BUCKET_URL_MIRROR2 ?? process.env.NEXT_PUBLIC_S3_BUCKET_URL;
1212

1313
export const RECAPTCHA_SITE_KEY = process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY ?? '';
14+
export const RECAPTCHA_V3_SITE_KEY = process.env.NEXT_PUBLIC_RECAPTCHA_V3_SITE_KEY ?? '';
1415

1516
/** MapTiler API key for basemaps (Streets/Satellite) and geocoding. */
1617
export const MAPTILER_API_KEY = process.env.NEXT_PUBLIC_MAPTILER_API_KEY ?? '';

app/src/app/utils/api/factory.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {API_URL} from './constants';
2+
import {fetchWithSession} from './session';
23
import {HTTP_METHOD} from 'next/dist/server/web/http';
34
import {ClientSession} from '@/app/lib/auth0';
45
export type QueryParams = Record<string, string | number | boolean | (string | number)[]>;
@@ -67,7 +68,7 @@ export const make = (path: string) => {
6768
}
6869

6970
try {
70-
const response = await fetch(fullPath, fetchOptions);
71+
const response = await fetchWithSession(fullPath, fetchOptions);
7172

7273
if (!response.ok) {
7374
const error = await response.json();

app/src/app/utils/api/msgpack.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {decode, encode} from '@msgpack/msgpack';
22
import {API_URL} from './constants';
3+
import {fetchWithSession} from './session';
34

45
type ApiResult<T> = {ok: true; response: T} | {ok: false; error: {detail: string}};
56

@@ -37,7 +38,7 @@ export async function getMsgpack<T>(
3738
queryParams?: QueryParams
3839
): Promise<ApiResult<T>> {
3940
try {
40-
const response = await fetch(buildUrl(path, queryParams), {
41+
const response = await fetchWithSession(buildUrl(path, queryParams), {
4142
headers: {Accept: 'application/msgpack'},
4243
});
4344
if (!response.ok) return {ok: false, error: await readError(response)};
@@ -54,7 +55,7 @@ export async function putMsgpack<TBody, TResponse>(
5455
): Promise<ApiResult<TResponse>> {
5556
try {
5657
const encoded = encode(body);
57-
const response = await fetch(buildUrl(path), {
58+
const response = await fetchWithSession(buildUrl(path), {
5859
method: 'PUT',
5960
headers: {
6061
'Content-Type': 'application/msgpack',

app/src/app/utils/api/session.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import {API_URL, RECAPTCHA_V3_SITE_KEY} from './constants';
2+
3+
/**
4+
* Silent reCAPTCHA v3 session tokens. The backend mints a session token from a
5+
* reCAPTCHA v3 token (POST /api/session) and gated endpoints require it in the
6+
* X-Districtr-Session header. Everything here is best-effort: any failure
7+
* (script blocked, Google down, backend error) yields null and the request
8+
* proceeds without the header.
9+
*/
10+
11+
declare global {
12+
interface Window {
13+
grecaptcha?: {
14+
ready: (cb: () => void) => void;
15+
execute: (siteKey: string, options: {action: string}) => Promise<string>;
16+
};
17+
}
18+
}
19+
20+
const STORAGE_KEY = 'districtr_session';
21+
// Refresh when within 5 minutes of expiry.
22+
const EXPIRY_BUFFER_MS = 5 * 60 * 1000;
23+
24+
type CachedSession = {token: string; expiresAt: number};
25+
26+
let cached: CachedSession | null = null;
27+
let inflight: Promise<string | null> | null = null;
28+
let scriptPromise: Promise<void> | null = null;
29+
30+
const isFresh = (session: CachedSession | null): session is CachedSession =>
31+
!!session && session.expiresAt - EXPIRY_BUFFER_MS > Date.now();
32+
33+
const readStorage = (): CachedSession | null => {
34+
try {
35+
const raw = window.localStorage.getItem(STORAGE_KEY);
36+
if (!raw) return null;
37+
const parsed = JSON.parse(raw);
38+
if (typeof parsed?.token === 'string' && typeof parsed?.expiresAt === 'number') {
39+
return parsed;
40+
}
41+
} catch {
42+
// ignore storage/parse errors
43+
}
44+
return null;
45+
};
46+
47+
const writeStorage = (session: CachedSession) => {
48+
try {
49+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
50+
} catch {
51+
// ignore storage errors (private mode, quota)
52+
}
53+
};
54+
55+
const loadRecaptchaScript = (): Promise<void> => {
56+
if (window.grecaptcha) return Promise.resolve();
57+
if (!scriptPromise) {
58+
scriptPromise = new Promise<void>((resolve, reject) => {
59+
const script = document.createElement('script');
60+
script.src = `https://www.google.com/recaptcha/api.js?render=${RECAPTCHA_V3_SITE_KEY}`;
61+
script.async = true;
62+
script.onload = () => resolve();
63+
script.onerror = () => {
64+
scriptPromise = null; // allow retry on a later call
65+
reject(new Error('recaptcha script failed to load'));
66+
};
67+
document.head.appendChild(script);
68+
});
69+
}
70+
return scriptPromise;
71+
};
72+
73+
const mintSession = async (): Promise<string | null> => {
74+
try {
75+
await loadRecaptchaScript();
76+
const grecaptcha = window.grecaptcha;
77+
if (!grecaptcha) return null;
78+
await new Promise<void>(resolve => grecaptcha.ready(resolve));
79+
const recaptchaToken = await grecaptcha.execute(RECAPTCHA_V3_SITE_KEY, {action: 'session'});
80+
const response = await fetch(`${API_URL || ''}/api/session`, {
81+
method: 'POST',
82+
headers: {'Content-Type': 'application/json'},
83+
body: JSON.stringify({recaptcha_token: recaptchaToken}),
84+
});
85+
if (!response.ok) return null;
86+
const data = await response.json();
87+
const expiresAt = Date.parse(data.expires_at);
88+
if (typeof data.token !== 'string' || isNaN(expiresAt)) return null;
89+
cached = {token: data.token, expiresAt};
90+
writeStorage(cached);
91+
return cached.token;
92+
} catch {
93+
return null;
94+
}
95+
};
96+
97+
/**
98+
* Get a session token for the X-Districtr-Session header, minting one via
99+
* silent reCAPTCHA v3 if needed. Never throws; returns null on any failure,
100+
* on the server, or when no site key is configured.
101+
*/
102+
export async function getSessionToken(): Promise<string | null> {
103+
if (typeof window === 'undefined' || !RECAPTCHA_V3_SITE_KEY) return null;
104+
if (isFresh(cached)) return cached.token;
105+
const stored = readStorage();
106+
if (isFresh(stored)) {
107+
cached = stored;
108+
return stored.token;
109+
}
110+
if (!inflight) {
111+
inflight = mintSession().finally(() => {
112+
inflight = null;
113+
});
114+
}
115+
return inflight;
116+
}
117+
118+
/**
119+
* fetch() that attaches the X-Districtr-Session header when a token is
120+
* available and, on a 401 {"detail": "session_required"} response, re-mints
121+
* the session and retries the request once.
122+
*/
123+
export async function fetchWithSession(url: string, init: RequestInit = {}): Promise<Response> {
124+
const headers = new Headers(init.headers);
125+
const token = await getSessionToken();
126+
if (token) headers.set('X-Districtr-Session', token);
127+
const response = await fetch(url, {...init, headers});
128+
if (response.status !== 401) return response;
129+
const detail = await response
130+
.clone()
131+
.json()
132+
.then(error => error?.detail)
133+
.catch(() => null);
134+
if (detail !== 'session_required') return response;
135+
clearSessionToken();
136+
const freshToken = await getSessionToken();
137+
if (!freshToken) return response;
138+
headers.set('X-Districtr-Session', freshToken);
139+
return fetch(url, {...init, headers});
140+
}
141+
142+
/** Clear the cached session token (used when the backend rejects it). */
143+
export function clearSessionToken() {
144+
cached = null;
145+
if (typeof window !== 'undefined') {
146+
try {
147+
window.localStorage.removeItem(STORAGE_KEY);
148+
} catch {
149+
// ignore storage errors
150+
}
151+
}
152+
}

backend/app/cms/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ async def list_cms_content(
193193

194194
if author is not None:
195195
logger.info("filtering by author")
196-
query = query.where(CMSModel.auth == author)
196+
query = query.where(CMSModel.author == author)
197197

198198
query = query.offset(offset).limit(limit)
199199
results = session.exec(query).all()

backend/app/comments/main.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from sqlalchemy.dialects.postgresql import insert
1414
from sqlalchemy import text, func, select, String, Select, update, delete
1515

16-
from app.core.security import auth, TokenScope
16+
from app.core.security import auth, require_session, TokenScope
1717
from sqlalchemy.sql import or_, and_, exists, literal, cast, case
1818

1919
from app.core.dependencies import get_protected_document, validate_document_exists
@@ -1078,7 +1078,11 @@ async def list_district_comments_admin(
10781078
return results
10791079

10801080

1081-
@router.post("/flag", status_code=status.HTTP_200_OK)
1081+
@router.post(
1082+
"/flag",
1083+
status_code=status.HTTP_200_OK,
1084+
dependencies=[Depends(require_session)],
1085+
)
10821086
async def flag_comment(
10831087
body: FlagCommentRequest,
10841088
session: Session = Depends(get_session),

0 commit comments

Comments
 (0)