브라우저로 내려간 값은 무엇이든 공개다. VITE_LEAD_TOKEN 이 번들에 평문으로
박혀 있었고(실제로 추출해 확인했다) 토큰은 보호 장치가 되지 못했다. 토큰을
새로 발급해도 똑같이 공개된다.
- api/lead.js 신규. 토큰과 Apps Script 주소를 서버 환경변수(LEAD_TOKEN·
LEAD_ENDPOINT)로만 들고 요청을 전달한다. VITE_ 접두사를 쓰지 않아 번들에
들어가지 않는다.
- Origin 허용 목록으로 webforai.kr·www·aio2o.webforai.kr 만 받는다. 토큰과
달리 Origin 은 브라우저가 채우고 스크립트가 바꿀 수 없다. 다른 사이트에
폼을 심어 이 주소를 때리는 것을 막는다. aio2o 는 정적 배포라 자체 함수를
둘 수 없어 이 함수를 같이 쓴다(교차 출처라 CORS 를 명시).
- submitLead 는 같은 출처 /api/lead 로 보낸다. 클라이언트 환경변수가 없어졌다.
- vercel.json 의 전체 경로 리라이트에서 /api 를 제외한다. 안 그러면 함수
요청까지 index.html 로 삼킨다.
Vercel 환경변수: LEAD_ENDPOINT·LEAD_TOKEN 추가, VITE_LEAD_* 2개 삭제.
Vite 는 코드에서 안 쓰더라도 VITE_ 접두사 변수를 전부 번들에 넣기 때문에
삭제까지 해야 토큰이 사라진다(삭제 전 재배포에서 여전히 검출됐다).
검증: 운영 번들에서 토큰 0건·script.google.com 0건. /api/lead 정상 응답
{"ok":true,"mailed":true}. 다른 Origin 403. SPA 라우트(/discovery/:id·
/actions/:id) 200 유지. 실제 폼 제출 접수 확인.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
107 lines
4.6 KiB
JavaScript
107 lines
4.6 KiB
JavaScript
/**
|
|
* 문의 폼 수신 프록시 (Vercel 서버리스 함수).
|
|
*
|
|
* 왜 두는가
|
|
* 전에는 브라우저가 Apps Script 주소와 토큰을 직접 들고 호출했다. 브라우저로 내려간 값은
|
|
* 무엇이든 공개라 토큰이 번들에 평문으로 박혔고(실제로 추출해서 확인했다), 보호 장치가
|
|
* 되지 못했다. 이제 토큰은 이 함수의 서버 환경변수에만 둔다. 번들에는 들어가지 않는다.
|
|
*
|
|
* 무엇을 더 막는가
|
|
* 토큰과 달리 Origin 은 브라우저가 채우고 스크립트가 바꿀 수 없다. 허용 목록에 없는
|
|
* 곳에서 온 요청은 여기서 끊는다. 다른 사이트에 폼을 심어 이 주소를 때리는 것을 막는다.
|
|
* (curl 처럼 브라우저가 아닌 호출은 Origin 이 없다. 그건 Apps Script 쪽 속도 제한이 받는다.)
|
|
*
|
|
* 환경변수 (Vercel 프로젝트 설정, 서버 전용 — VITE_ 접두사를 붙이지 않는다)
|
|
* LEAD_ENDPOINT Apps Script 웹앱 /exec 주소
|
|
* LEAD_TOKEN 스크립트의 SHARED_TOKEN 과 같은 값
|
|
*/
|
|
|
|
/** 이 주소들에서 온 요청만 받는다. aio2o 사이트는 정적 배포라 함수를 둘 수 없어 여기를 같이 쓴다. */
|
|
const ALLOWED_ORIGINS = [
|
|
'https://webforai.kr',
|
|
'https://www.webforai.kr',
|
|
'https://aio2o.webforai.kr',
|
|
];
|
|
|
|
export default async function handler(req, res) {
|
|
const origin = req.headers.origin || '';
|
|
const allowed = ALLOWED_ORIGINS.includes(origin);
|
|
|
|
// 교차 출처(aio2o)에서 부르므로 CORS 를 명시한다. 허용 목록에 없으면 헤더를 주지 않는다.
|
|
if (allowed) {
|
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
res.setHeader('Vary', 'Origin');
|
|
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
res.setHeader('Access-Control-Max-Age', '86400');
|
|
}
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
return res.status(allowed ? 204 : 403).end();
|
|
}
|
|
if (req.method !== 'POST') {
|
|
return res.status(405).json({ ok: false, error: 'method not allowed' });
|
|
}
|
|
// Origin 이 있는데 목록에 없으면 다른 사이트에서 부른 것이다. 비어 있는 경우(브라우저가 아닌
|
|
// 호출)는 여기서 판단하지 않고 통과시킨다. Apps Script 의 속도 제한이 상한을 건다.
|
|
if (origin && !allowed) {
|
|
return res.status(403).json({ ok: false, error: 'forbidden origin' });
|
|
}
|
|
|
|
const endpoint = process.env.LEAD_ENDPOINT;
|
|
const token = process.env.LEAD_TOKEN ?? '';
|
|
if (!endpoint) {
|
|
// 설정이 없는데 접수된 척하지 않는다. 화면이 메일 폴백을 띄우게 실패로 돌려준다.
|
|
console.error('[api/lead] LEAD_ENDPOINT 가 설정되지 않았습니다.');
|
|
return res.status(500).json({ ok: false, error: 'unconfigured' });
|
|
}
|
|
|
|
const body = typeof req.body === 'string' ? safeParse(req.body) : req.body ?? {};
|
|
if (!body || typeof body !== 'object') {
|
|
return res.status(400).json({ ok: false, error: 'bad body' });
|
|
}
|
|
if (!String(body.name ?? '').trim()) {
|
|
return res.status(400).json({ ok: false, error: 'name required' });
|
|
}
|
|
|
|
try {
|
|
// Apps Script 는 CORS 프리플라이트에 응답하지 않지만, 서버 대 서버 호출이라 상관없다.
|
|
// 리다이렉트를 따라가야 실제 응답(JSON)을 받는다.
|
|
const upstream = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'text/plain;charset=utf-8' },
|
|
body: JSON.stringify({
|
|
name: body.name,
|
|
company: body.company ?? '',
|
|
phone: body.phone ?? '',
|
|
email: body.email ?? '',
|
|
message: body.message ?? '',
|
|
// 어느 사이트에서 왔는지 시트에 남긴다. 값은 클라이언트가 보내지만 참고용이라 그대로 둔다.
|
|
source: body.source ?? '',
|
|
website: body.website ?? '', // 허니팟. 스크립트가 보고 걸러낸다.
|
|
token,
|
|
submittedAt: new Date().toISOString(),
|
|
}),
|
|
redirect: 'follow',
|
|
});
|
|
const text = await upstream.text();
|
|
const data = safeParse(text);
|
|
if (!upstream.ok || !data || !data.ok) {
|
|
console.error('[api/lead] 업스트림 실패', upstream.status, text.slice(0, 200));
|
|
return res.status(502).json({ ok: false, error: 'upstream failed' });
|
|
}
|
|
return res.status(200).json({ ok: true, mailed: data.mailed ?? null });
|
|
} catch (e) {
|
|
console.error('[api/lead] 전달 실패', e);
|
|
return res.status(502).json({ ok: false, error: 'upstream error' });
|
|
}
|
|
}
|
|
|
|
function safeParse(s) {
|
|
try {
|
|
return JSON.parse(s);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|