first commit
This commit is contained in:
57
src/app/api/faces/route.ts
Normal file
57
src/app/api/faces/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {upstreamRaw} from '@/services/api/apiClient';
|
||||
import {withUpstream} from '@/features/auth/services/upstreamSession';
|
||||
import {failResponse} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/faces?src=/api/faces/<uuid>.jpg — an authenticated photo, proxied.
|
||||
*
|
||||
* A browser `<img>` cannot send an Authorization header, and the platform's
|
||||
* own image URLs require one. The alternatives were fetch + createObjectURL +
|
||||
* revoke-on-unmount at every avatar — which leaks hundreds of copies of one
|
||||
* photograph on a screen left open all afternoon — or this: one hop through
|
||||
* the origin that already holds the token.
|
||||
*
|
||||
* ── Why `src` is validated rather than trusted ───────────────────────────
|
||||
* An unchecked pass-through would be an open proxy that attaches the
|
||||
* merchant's bearer token to any URL an attacker can get into a page. Only
|
||||
* same-origin platform paths under /api/faces/ are forwarded.
|
||||
*
|
||||
* Every hand-out of a photo is written to the platform's audit log, so this
|
||||
* must be requested once per screen rather than once per component: two
|
||||
* components asking for the same face puts two rows in "who looked at my
|
||||
* customers" for one glance at one person.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const src = new URL(req.url).searchParams.get('src') ?? '';
|
||||
|
||||
// Relative, no traversal, and inside the faces namespace. Anything else is
|
||||
// refused rather than sanitised — a "cleaned" attacker-supplied URL is still
|
||||
// attacker-supplied.
|
||||
if (!src.startsWith('/api/faces/') || src.includes('..')) {
|
||||
return Response.json(
|
||||
{error: {code: 'bad_request', message: 'Not a valid image reference.'}},
|
||||
{status: 400},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await withUpstream((token) =>
|
||||
upstreamRaw({path: src, accessToken: token}),
|
||||
);
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': upstream.headers.get('content-type') ?? 'image/jpeg',
|
||||
// Private: this is one merchant's customer, and a shared cache holding
|
||||
// it would serve it across tenants.
|
||||
'cache-control': 'private, max-age=300',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return failResponse(err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user