feat(contact): 문의 전송을 서버 함수로 옮겨 토큰 노출 제거
브라우저로 내려간 값은 무엇이든 공개다. 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>
This commit is contained in:
parent
e0f1d4df96
commit
00b8339ca2
106
api/lead.js
Normal file
106
api/lead.js
Normal file
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 문의 폼 수신 프록시 (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;
|
||||
}
|
||||
}
|
||||
@ -1,21 +1,14 @@
|
||||
/**
|
||||
* 문의 폼 전송.
|
||||
*
|
||||
* 받는 쪽은 Google Apps Script 웹앱이다(scripts/apps-script/lead-intake.gs).
|
||||
* 그 스크립트 하나가 구글시트에 행을 추가하고 알림 메일까지 보낸다.
|
||||
* 이 사이트는 서버가 없는 SPA 라 중간 서버를 두지 않았다.
|
||||
* 같은 출처의 /api/lead 로 보낸다. 그 함수가 토큰을 붙여 Apps Script 로 넘기고,
|
||||
* 스크립트가 구글시트에 행을 추가하고 알림 메일을 보낸다.
|
||||
*
|
||||
* Apps Script 웹앱은 CORS 프리플라이트에 응답하지 않는다. 그래서 단순 요청(simple request)
|
||||
* 조건을 지켜 Content-Type 을 text/plain 으로 보낸다. 스크립트가 본문을 JSON 으로 파싱한다.
|
||||
* 헤더를 application/json 으로 바꾸면 프리플라이트가 발생해 브라우저에서 차단된다.
|
||||
* 2026-10-01 이전에는 브라우저가 Apps Script 주소와 토큰을 직접 들고 호출했다.
|
||||
* 브라우저로 내려간 값은 무엇이든 공개라 토큰이 번들에 평문으로 박혔다(실제로 추출해 확인).
|
||||
* 토큰을 서버로 옮겨 번들에서 없앴고, 그 덕에 클라이언트 환경변수도 필요 없어졌다.
|
||||
*/
|
||||
|
||||
/** Vercel 환경변수 VITE_LEAD_ENDPOINT (Apps Script 배포 URL). 없으면 전송하지 않는다. */
|
||||
const ENDPOINT = import.meta.env.VITE_LEAD_ENDPOINT as string | undefined;
|
||||
|
||||
/** 선택. 스크립트 쪽 SHARED_TOKEN 과 같은 값을 넣으면 외부의 무작위 호출을 걸러낸다. */
|
||||
const TOKEN = import.meta.env.VITE_LEAD_TOKEN as string | undefined;
|
||||
|
||||
export interface LeadInput {
|
||||
name: string;
|
||||
company: string;
|
||||
@ -26,30 +19,22 @@ export interface LeadInput {
|
||||
source: string;
|
||||
/**
|
||||
* 허니팟. 사람에게는 보이지 않는 칸이라 항상 빈 문자열이어야 한다.
|
||||
* 값이 차 있으면 스크립트가 봇으로 보고 저장하지 않는다.
|
||||
* 이름은 Apps Script 의 HONEYPOT_FIELD 와 같아야 한다.
|
||||
* 값이 차 있으면 Apps Script 가 봇으로 보고 저장하지 않는다.
|
||||
*/
|
||||
website: string;
|
||||
}
|
||||
|
||||
export type LeadResult = { ok: true } | { ok: false; reason: 'unconfigured' | 'failed' };
|
||||
export type LeadResult = { ok: true } | { ok: false; reason: 'failed' };
|
||||
|
||||
export async function submitLead(input: LeadInput): Promise<LeadResult> {
|
||||
if (!ENDPOINT) {
|
||||
// 엔드포인트가 없는데 성공했다고 말하지 않는다. 화면은 메일 폴백을 보여준다.
|
||||
console.warn('[submitLead] VITE_LEAD_ENDPOINT 가 설정되지 않았습니다.');
|
||||
return { ok: false, reason: 'unconfigured' };
|
||||
}
|
||||
try {
|
||||
const res = await fetch(ENDPOINT, {
|
||||
const res = await fetch('/api/lead', {
|
||||
method: 'POST',
|
||||
// 프리플라이트를 피하기 위한 simple request. 위 주석 참고.
|
||||
headers: { 'Content-Type': 'text/plain;charset=utf-8' },
|
||||
body: JSON.stringify({ ...input, token: TOKEN ?? '', submittedAt: new Date().toISOString() }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) return { ok: false, reason: 'failed' };
|
||||
const data = await res.json().catch(() => null);
|
||||
// 스크립트는 { ok: true } 를 돌려준다. 형식이 다르면 실패로 본다.
|
||||
// 성공했다고 말할 근거가 없으면 실패로 둔다. 화면은 메일 폴백을 보여준다.
|
||||
return data && data.ok ? { ok: true } : { ok: false, reason: 'failed' };
|
||||
} catch (e) {
|
||||
console.error('[submitLead] 전송 실패', e);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{ "source": "/(.*)", "destination": "/index.html" }
|
||||
{ "source": "/((?!api/).*)", "destination": "/index.html" }
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user