見出し画像

【IT】CSRではAPIキーが漏れる → SSRプロキシで解決(SvelteKit + Vercel想定)

皆さま
こんにちは

アプリを作成していたときにふと気づいたことがあり、
記事にました。

1. 背景

Capacitor + SvelteKit でお天気アプリを作成した際、
`.env` に API_KEY を置いても、ブラウザの開発者ツールの Network で確認できてしまいます。

<例> 

CSR(Client Side Rendering)では、ビルド時にAPI_KEYがJSに埋め込まれるため、
.env にしても秘匿にはなりません。

これは特定のフレームワークの問題ではなく、CSRという仕組みの性質となります。もちろんReactでも同様なことが起こります。

今回は、 SSR(サーバ)にキーを置く:CSR → 自前SSR → 外部API のプロキシ構成にて対応したい思います。
また、合わせて簡単なセキュリティ対策も行います。

本文は SvelteKit(SSR)をVercelに載せてAPIプロキシ化するための実装メモとなります。



2. CSR / SSR / SSG の違い

  • CSR (Client Side Rendering)
    ブラウザが「空HTML + JS」を受け取り、画面を描画する方式。
    API呼び出しもクライアント側で行うため、通信内容はすべて露出する。  

  • SSR (Server Side Rendering)
    サーバがリクエストごとにHTMLやデータを生成して返す方式。
    API呼び出しはサーバ側で行うため、API_KEYはクライアントに漏れない。  

  • SSG (Static Site Generation)
    ビルド時に静的HTMLを生成し、配信する方式。
    表示は速いが、データ更新には再ビルドが必要。  


3. 解決策:SSRでAPIプロキシを挟む

お天気アプリとして以下の構成を考えてみました。

構成イメージ:

[CSRアプリ (Capacitor)]
|
| ① fetch /api/weather?q=Tokyo
v
[SSRサーバ (SvelteKit)]
|
| ② fetch https://www.example.com/...&key=API_KEY
v
[Weather API]
|
| ③ JSON response
v
[SSRサーバ]
|
| ④ 必要項目のみ返却
v
[CSRアプリ]


4. 実装例(SvelteKit SSR 側)

`.env` にAPIキーを保存(ロカールで動作確認のきに使用)

プロジェクトディレクトリ直下にAPI_KEYを保存します。

WEATHER_API_URL=https://www.example.com/xxx/yyyyyy.json (API指定のURL)
WEATHER_API_KEY=xxxxx(契約のキー)

/src/routes/api/weather/+server.ts:

<作成のポイント>

  • APIキーは サーバ側のみ(`$env/static/private` 経由)

  • CORSを厳格化(Originホワイトリスト + Vercelプレビュー *.vercel.app 任意許可)

  • レート制限短期キャッシュレスポンス縮小HTTPS強制 など最小ハードニング

import type { RequestHandler } from './$types';
import { WEATHER_API_KEY, WEATHER_API_URL } from '$env/static/private';

/* =========================
   許可する Origin 一覧
   ========================= */
// 固定で許可するもの(あなたの環境に合わせて書き換え)
const FIXED_ALLOWED_ORIGINS = new Set<string>([
  // 1) 本番の独自ドメイン(将来持つ場合の例)
  'https://example.com',

  // 実際のVercelのURLへ変更してください。
  // 2) Vercel 本番
  'https://your-app.vercel.app',

  // 3) Capacitor アプリ内 WebView(iOS/Android)
  'http://localhost',

  // ★Capacitor WebView の既定Origin
  'capacitor://localhost'
]);

/** Vercel プレビュー URL (ブランチごと *.vercel.app) も許可するか? */
const ALLOW_VERCEL_PREVIEW = true;

/* =========================
   簡易キャッシュ & レート制限
   ========================= */
type CacheEntry = { body: any; expireAt: number };
const cache = new Map<string, CacheEntry>();
const CACHE_TTL_MS = 60_000; // 60 秒

const rateBuckets = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT = 30;  // 1 分あたり
const WINDOW_MS = 60_000;

const now = () => Date.now();

/* =========================
   CORS 判定
   ========================= */
function isAllowedOrigin(origin: string) {
  try {
    const u = new URL(origin);

    // ★ 開発時: localhost/127.0.0.1 はポート任意で許可
    if (
      u.protocol === 'http:' &&
      (u.hostname === 'localhost' || u.hostname === '127.0.0.1')
    ) {
      return true; // 例: http://localhost:5173, http://127.0.0.1:5173
    }

    // 固定リスト完全一致
    if (FIXED_ALLOWED_ORIGINS.has(origin)) return true;

    // Vercel プレビュー (任意のブランチURLを許可)
    if (ALLOW_VERCEL_PREVIEW && u.protocol === 'https:' && u.hostname.endsWith('.vercel.app')) {
      return true;
    }

    return false;
  } catch {
    return false;
  }
}

function buildCorsHeaders(origin: string | null) {
  const headers: Record<string, string> = {
    'access-control-allow-methods': 'GET, OPTIONS',
    'access-control-allow-headers': 'content-type, authorization',
    'access-control-max-age': '600',
    'vary': 'Origin' // CDN キャッシュ分離
  };
  // 許可していないときは allow-origin を付けない(空文字は返さない)
  if (origin && isAllowedOrigin(origin)) {
    headers['access-control-allow-origin'] = origin;
  }
  return headers;
}

/* =========================
   レスポンス縮小(必要項目のみ)
   ========================= */
function shrink(json: any) {
  return {
    location: {
      name: json?.location?.name,
      country: json?.location?.country
    },
    current: {
      temp_c: json?.current?.temp_c,
      condition: {
        text: json?.current?.condition?.text,
        // //cdn... を https で補完
        icon: json?.current?.condition?.icon
          ? `https:${json.current.condition.icon}`
          : null
      }
    }
  };
}

function ensureHttps(u: string) {
  try {
    const url = new URL(u.startsWith('//') ? `https:${u}` : u);
    url.protocol = 'https:';
    return url.toString();
  } catch {
    return `https://${u.replace(/^https?:\/\//, '')}`;
  }
}

function checkRateLimit(ip: string) {
  const t = now();
  const b = rateBuckets.get(ip);
  if (!b || t > b.resetAt) {
    rateBuckets.set(ip, { count: 1, resetAt: t + WINDOW_MS });
    return { ok: true, remaining: RATE_LIMIT - 1, resetAt: t + WINDOW_MS };
  }
  if (b.count >= RATE_LIMIT) return { ok: false, remaining: 0, resetAt: b.resetAt };
  b.count++;
  return { ok: true, remaining: RATE_LIMIT - b.count, resetAt: b.resetAt };
}

async function safePeek(res: Response) {
  try { return await res.text(); } catch { return '<no-body>'; }
}

/* =========================
   CORS: プリフライト
   ========================= */
export const OPTIONS: RequestHandler = async ({ request }) => {
  const origin = request.headers.get('origin');
  return new Response(null, { status: 204, headers: buildCorsHeaders(origin) });
};

/* =========================
   GET: 本体
   ========================= */
export const GET: RequestHandler = async (event) => {
  const { url, request, getClientAddress, fetch: skFetch } = event;
  const origin = request.headers.get('origin');

  // HTTPS 強制(プロキシ配下想定)
  const xfProto = request.headers.get('x-forwarded-proto');
  if (xfProto === 'http') {
    const httpsURL = new URL(url);
    httpsURL.protocol = 'https:';
    return Response.redirect(httpsURL.toString(), 301);
  }

  const baseHeaders = {
    'content-type': 'application/json; charset=utf-8',
    ...buildCorsHeaders(origin)
  };

  // レート制限
  const ip = getClientAddress();
  const rate = checkRateLimit(ip);
  if (!rate.ok) {
    return new Response(JSON.stringify({ error: 'Too Many Requests' }), {
      status: 429,
      headers: { ...baseHeaders, 'retry-after': Math.ceil((rate.resetAt - now()) / 1000).toString() }
    });
  }

  // city パラメータ
  const city = url.searchParams.get('city');
  if (!city) {
    return new Response(JSON.stringify({ error: 'Missing city' }), { status: 400, headers: baseHeaders });
  }

  // キャッシュ確認(60秒)
  const cacheKey = `weather:${city.toLowerCase()}`;
  const hit = cache.get(cacheKey);
  if (hit && hit.expireAt > now()) {
    return new Response(JSON.stringify(hit.body), {
      status: 200,
      headers: { ...baseHeaders, 'cache-control': 'public, max-age=30', 'x-cache': 'HIT' }
    });
  }

  // 外部 API 呼び出し(サーバ側でAPIキー秘匿)
  const endpoint =
    `${ensureHttps(WEATHER_API_URL)}?key=${encodeURIComponent(WEATHER_API_KEY)}&q=${encodeURIComponent(city)}&aqi=no`;

  const upstream = await skFetch(endpoint);
  if (!upstream.ok) {
    console.error('Weather upstream error', upstream.status, await safePeek(upstream));
    return new Response(JSON.stringify({ error: 'Upstream error' }), { status: 502, headers: baseHeaders });
  }

  const raw = await upstream.json();
  const body = shrink(raw);

  // キャッシュ格納
  cache.set(cacheKey, { body, expireAt: now() + CACHE_TTL_MS });

  return new Response(JSON.stringify(body), {
    status: 200,
    headers: { ...baseHeaders, 'cache-control': 'public, max-age=30', 'x-cache': 'MISS' }
  });
};

Vercel設定(環境変数)

プロジェクト → Settings → Environment Variables に設定。

WEATHER_API_URL=https://www.example.com/xxx/yyyyyy.json (API指定のURL)
WEATHER_API_KEY=xxxxx(契約のキー)

読み取りは $env/static/private。ビルド時にサーバ側に埋め込まれ、クライアントへは配布されない


5. CORS運用(ポイント)

  • 許可リスト固定

    • https://your-app.vercel.app(本番)

    • http://localhost(Capacitor WebView)

    • https://example.com(将来の独自ドメイン例)

    • capacitor://localhost(Capacitor既定Origin)

  • Vercelプレビュー許可:ALLOW_VERCEL_PREVIEW = true で *.vercel.app をHTTPS限定で一括許可

  • 未許可Origin:access-control-allow-origin を 付与しない(空文字は返さない)

  • CDN混線防止:Vary: Origin を必ず付与


6. セキュリティ/運用メモ

  1. APIキーをフロントに出さない:$env/static/private を使い、+server.ts内で消費

  2. レート制限:ここではメモリ実装(1分30req/IP)。水平スケール時は Upstash/Redis 等へ移行

  3. レスポンス縮小:必要項目のみ返す(露出面/転送料削減)

  4. 短期キャッシュ:60秒のメモリキャッシュ。多インスタンス時は Redis or CDN に置換

  5. HTTPS強制:x-forwarded-proto: http を検知して 301 リダイレクト

  6. CapacitorのOrigin:配布後も http://localhost or capacitor://localhost(固定で扱える)

  7. ログ:console.error はVercelのLogsで可視化(漏洩防止のため外部APIの生エラーは出し過ぎない)

7. 実装例(SvelteKit CSR 側)

クライアント側(Capacitorアプリ):
ディプロしたVercel上のURLからAPIを参照するように環境変数を変更

/src/routes/+page.svelte:

<script lang="ts">
   ・
  import axios from "axios";
   ・
   ・
 // 実際のVercelのURLへ変更してください。
  const WEATER_API = "https://your-app.vercel.app/api/weather?city=";
   ・
   ・
   


  // 天気を取得する関数
  const getWeather = (city: string) => {
   ・
   ・
    // APIリクエスト
    axios
      // .get(`${API_URL}?key=${API_KEY}&q=${city}&aqi=no`)
      .get(`${WEATER_API}${city}`) →変更
   ・
   ・
  };
   ・
   ・

</script>

8. 付録:CSR / SSR / SSG(最短メモ)

  • CSR:ブラウザで描画/APIもブラウザ → 鍵は漏れる

  • SSR:サーバで描画/APIはサーバ → 鍵はサーバ内

  • SSG:ビルド時生成/動的データは再ビルド or 別API → 鍵の扱いはSSR側で


9 .まとめ(運用指針)

  • フロント(CSR)だけで外部APIを直接叩かない

  • SSR(またはサーバレス関数)を噛ませて鍵を隠す

  • CORS・レート制限・キャッシュで最低限のハードニングを入れる


では
また!


いいなと思ったら応援しよう!