Skip to main content

Node.jsで安全なAPIゲートウェイを構築する方法

著者

Florian Rappl

2022年12月28日

0 分で読めます

マイクロサービスには、モノリスと比べて大きな利点があります。開発規模をより簡単に拡大でき、インフラのスケーリングも細かく制御できます。さらに、小規模なアップデートや段階的なロールアウトを数多く実施できるため、市場投入までの時間を大幅に短縮できます。

こうしたメリットがある一方、マイクロサービスアーキテクチャには、サービスに外部からアクセスできないという課題があります。幸い、APIゲートウェイを使えばこの問題を解決できます。

APIゲートウェイは、フロントエンドアプリケーション(Webサイトやネイティブアプリなど)からバックエンドのあらゆる機能へアクセスするための明確な経路を提供します。マイクロサービスを集約する役割を果たすほか、認証や認可などの共通課題を処理するミドルウェアとしても機能します。また、サービスをまとめて統合する際にも便利で、XMLやJSONなどの出力形式を単一の形式に統一できます。

APIゲートウェイは、セッション管理、入力値のサニタイズ、分散型サービス拒否(DDoS)攻撃対策、レート制限、トランザクションのログ記録を提供し、セキュリティと信頼性を確保するうえで重要な役割を果たします。

この記事では、Node.jsといくつかのオープンソースパッケージだけを使って、安全なAPIゲートウェイをゼロから構築します。必要なのは、ターミナルの基本的な知識、バージョン14以降のNode.js、そしてJavaScriptの知識だけです。完成したプロジェクトのコードはこちらで確認できます。

それでは始めましょう!

APIゲートウェイを作成する

Node.jsでWebサーバーをゼロから書くこともできますが、ここでは既存のフレームワークに複雑な処理を任せます。Node.jsで最も人気のある選択肢と言えるのがExpressです。軽量で十分な速さがあり、拡張も簡単です。さらに、必要なパッケージのほとんどをExpressに統合できます。

まず、新しいNode.jsプロジェクトを作成し、Expressをインストールしましょう。

npm init -y
npm install express --save

この2つのコマンドで、デフォルト設定を使って新しいNode.jsプロジェクトを作成し、expressパッケージをインストールしました。この時点で、プロジェクトディレクトリにpackage.jsonファイルとnode_modulesディレクトリがあるはずです。

次に、index.jsというファイルを作成し、package.jsonと同じ階層に配置します。ファイルには、まず次の内容を記述します。

const express = require("express");

const app = express();
const port = 3000;

app.get("/", (req, res) => {
  const { name = "user" } = req.query;
  res.send(`Hello ${name}!`);
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

この設定で、エンドポイントが1つある基本的なWebサーバーを実行できます。ターミナルで次のコマンドを実行して起動しましょう。

node index.js

ブラウザーでhttp://localhost:3000にアクセスしてください。画面に「Hello user!」と表示されます。?name=KingのようにURLにクエリを追加すると、「Hello King!」と表示されます。

次は認証を設定します。ここではexpress-sessionパッケージを使います。このパッケージはCookieを利用してエンドポイントを保護します。有効なセッションCookieを持たないユーザーには、HTTP 401 (Unauthorized)または403 (Forbidden)のステータスコードを含む適切なレスポンスが返されます。

また、パスワードを環境変数に保存して、より安全に管理します。dotenvパッケージを使うと、.envファイル内のSESSION_SECRET変数をprocess.envに読み込めます。

まず、コンソールでCtrl + Cを入力し、前のプロセスを停止してください。次に、以下のコマンドでexpress-sessionとdotenvをインストールします。

npm install express-session dotenv --save

安全なセッションの設定方法について詳しくは、GitHubのexpress-sessionのドキュメントを参照してください。たとえば、Cookieのsecureフラグをtrueに設定すると、HTTPS接続でのみ送信されるようになります。また、ログインやパスワード変更などの機密性の高い操作時にセッションIDを再生成するなど、セッション管理に関するほかのセキュリティ対策も講じてください。

次に、プロジェクトのルートディレクトリに.envファイルを作成し、以下のコードを追加します。

SESSION_SECRET=`<your_secret>`

慣例として、環境変数の名前は大文字で記述します。また、一意でランダムなシークレットを使ってください。強力なパスワードを使用するとよいでしょう。

index.jsに追加する内容は次のようになります(後ほど、解決策を組み立てる際にこのコードを使います)。

require("dotenv").config();

const session = require("express-session");

const secret = process.env.SESSION_SECRET;
const store = new session.MemoryStore();
const protect = (req, res, next) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    res.sendStatus(401);
  } else {
    next();
  }
};

app.use(
  session({
    secret,
    resave: false,
    saveUninitialized: true,
    store,
  })
);

この設定により、エンドポイントのアクセス制御に使えるメモリベースのセッション処理が実現します。新しいエンドポイントでこれを使い、ログインとログアウト専用のエンドポイントも用意しましょう。次のコードをindex.jsの末尾に追加します。

app.get("/login", (req, res) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    req.session.authenticated = true;
    res.send("Successfully authenticated");
  } else {
    res.send("Already authenticated");
  }
});

app.get("/logout", protect, (req, res) => {
  req.session.destroy(() => {
    res.send("Successfully logged out");
  });
});

app.get("/protected", protect, (req, res) => {
  const { name = "user" } = req.query;
  res.send(`Hello ${name}!`);
});

では、node index.jsコマンドを実行してコードを再起動し、動作を試してみましょう。

どのように動作するのでしょうか?基本的な利用の流れは次のとおりです。

  • /にアクセスすると、問題なく表示されます。

  • /protectedにアクセスすると、HTTPステータスコード401 (Unauthorized)が返されます。

  • /loginにアクセスすると自動的にログインし、「Successfully authenticated.」と表示されます。

  • もう一度/loginにアクセスすると、「Already authenticated.」と表示されます。

  • /protectedにアクセスすると、今度はページが表示されます。

  • /logoutにアクセスするとセッションがリセットされ、「Successfully logged out.」と表示されます。

  • その後、再び/protectedにアクセスすると、利用できなくなり、ステータスコード401が表示されます。

  • 再度/logoutにアクセスした場合も、ステータスコード401が返されます。

リクエストハンドラーの前にprotectミドルウェアを使うことで、ユーザーがログイン済みであることを確認し、エンドポイントを保護できます。さらに、/loginと/logoutのエンドポイントを設けることで、この機能を補完できます。

再びCtrl + Cでプロセスを終了し、APIゲートウェイの中心部分を説明する前に、ログ記録と適切なレート制限について見ていきましょう。

レート制限

レート制限を設定すると、APIへのアクセス回数を一定時間内の上限に抑えられます。通常のトラフィックやDoS攻撃による帯域幅の枯渇を防ぐことができます。特定の送信元からのトラフィックにレート制限を適用するよう設定でき、リクエストを処理する時間枠の算出や適用にはさまざまな方法があります。

まず、レート制限用のパッケージをインストールします。

npm install express-rate-limit --save

次に、パッケージを設定します。制限を適用したいルートより前に、このコードをindex.jsファイルに挿入してください。

const rateLimit = require("express-rate-limit");

app.use(
  rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 5, // 5 calls
  })
);

サーバーをnode index.jsコマンドで再起動し、最初のエンドポイントに何度かアクセスしてテストできます。15分以内にリクエストが5回の上限に達すると、「Too many requests, please try again later.」というメッセージが表示されます。デフォルトでは、express-rate-limitは適切なHTTPステータスコード429 (Too Many Requests)も返します。

次の手順に進む前に、プロセスを終了して制限をリセットしてください。

ログ記録

ログ記録にはwinstonを使えます。Express専用のミドルウェアexpress-winstonも用意されています。また、後から詳しく調べられるように、エンドポイントの応答時間も記録するとよいでしょう。そのために役立つパッケージがresponse-timeです。これらのパッケージをインストールしましょう。

npm install winston express-winston response-time --save

これらのパッケージの統合は簡単です。記録したいコードより前に、次のコードをindex.jsに追加してください。レート制限のコードより前に挿入するのがよいでしょう。

const winston = require("winston");
const expressWinston = require("express-winston");
const responseTime = require("response-time");

app.use(responseTime());

app.use(
  expressWinston.logger({
    transports: [new winston.transports.Console()],
    format: winston.format.json(),
    statusLevels: true,
    meta: false,
    msg: "HTTP {{req.method}} {{req.url}} {{res.statusCode}} {{res.responseTime}}ms",
    expressFormat: true,
    ignoreRoute() {
      return false;
    },
  })
);

この設定により、追加の出力が得られます。サーバーを起動して/にアクセスすると、コンソールには次のように表示されます。

Server running at http://localhost:3000
{"level":"info","message":"GET / 200 8ms","meta":{}}
{"level":"warn","message":"GET /favicon.ico 404 3ms","meta":{}}

次に進む準備ができたら、Ctrl + Cでプロセスを終了してください。

クロスオリジンリソース共有(CORS)

APIゲートウェイをフロントエンドとバックエンドのサービス間のレイヤーとして使うため、ここでクロスオリジンリソース共有(CORS)を処理します。CORSはブラウザーのセキュリティ機能で、特定のクロスオリジンリソースリクエスト(たとえば、www.company.comからapi.company.comへのリクエスト)をバックエンドが受け入れるようにします。

これを実現するには、メインのリクエストを送信する前に特別なリクエストを行います。このリクエストではOPTION HTTPメソッドを使用し、後続のリクエストを許可または拒否するための特別なヘッダーをレスポンスに含めるよう求めます。

ゲートウェイでCORSを有効にするには、corsパッケージをインストールします。この機会に、helmetという便利なパッケージを使ってセキュリティヘッダーも追加できます。以下のコードで両方のパッケージをインストールしましょう。

npm install cors helmet --save

次のように統合できます。

const cors = require("cors");
const helmet = require("helmet");

app.use(cors());
app.use(helmet());

この設定では、すべてのドメインからAPIにアクセスできます。より細かく設定することもできますが、ここでは上記の設定で十分です。

プロキシ

APIゲートウェイは主に、ビジネスロジックに関するリクエストやその他のHTTPリクエストをルーティングするため、専用のマイクロサービスに転送します。そのため、この転送を処理するプロキシパッケージが必要です。ここではhttp-proxy-middlewareを使います。以下のコードでインストールできます。

npm install http-proxy-middleware --save

次に、これを新しいエンドポイントとともに追加します。

const { createProxyMiddleware } = require("http-proxy-middleware");

app.use(
  "/search",
  createProxyMiddleware({
    target: "http://api.duckduckgo.com/",
    changeOrigin: true,
    pathRewrite: {
      [`^/search`]: "",
    },
  })
);

サーバーを起動して/search?q=x&format=jsonにアクセスすると、リクエストをプロキシ経由でhttp://api.duckduckgo.com/に転送して取得した結果が返されます。コードを実行する場合は、作業が終わったらプロセスを終了し、最後の変更に進めるようにしてください。

設定

必要な要素がそろったので、それらを連携させる設定を行いましょう。そのために、ほかのファイルと同じディレクトリにconfig.jsという新しいファイルを作成します。

require("dotenv").config();

exports.serverPort = 3000;
exports.sessionSecret = process.env.SESSION_SECRET;
exports.rate = {
  windowMs: 5 * 60 * 1000,
  max: 100,
};
exports.proxies = {
  "/search": {
    protected: true,
    target: "http://api.duckduckgo.com/",
    changeOrigin: true,
    pathRewrite: {
      [`^/search`]: "",
    },
  },
};

ここでは、APIゲートウェイに必要な基本設定を作成しました。ポート、Cookieセッションの暗号化キー、プロキシ転送先の各エンドポイントを設定しています。各プロキシのオプションはcreateProxyMiddleware関数のオプションと同じですが、protectedキーが追加されています。

secretとportの宣言、およびindex.js内のrateLimitの呼び出しも更新し、設定ファイルで定義した値を参照するようにしましょう。

const secret = config.sessionSecret;
...
const port = config.serverPort;
...
app.use(rateLimit(config.rate));

また、機能をconfig.jsに移したコードと、最初のエンドポイントのテストコードを削除する必要があります。index.jsから次のコードを削除してください。

require("dotenv").config();
...
app.use(
 "/search",
 createProxyMiddleware({
   target: "http://api.duckduckgo.com/",
   changeOrigin: true,
   pathRewrite: {
 [`^/search`]: "",
   },
 })
);
...
app.get("/", (req, res) => {
 const { name = "user" } = req.query;
 res.send(`Hello ${name}!`);
});
...
app.get("/protected", protect, (req, res) => {
 const { name = "user" } = req.query;
 res.send(`Hello ${name}!`);
});

完成したアプリ

いくつか小さな追加を行った、完成版のindex.jsファイルの内容を確認しましょう。

// import all the required packages
const cors = require("cors");
const express = require("express");
const session = require("express-session");
const rateLimit = require("express-rate-limit");
const expressWinston = require("express-winston");
const helmet = require("helmet");
const { createProxyMiddleware } = require("http-proxy-middleware");
const responseTime = require("response-time");
const winston = require("winston");
const config = require("./config");

// configure the application
const app = express();
const port = config.serverPort;
const secret = config.sessionSecret;
const store = new session.MemoryStore();

config.jsに記載したプロキシのprotectedプロパティの値を確認するロジックを追加します。値がfalseの場合、ここで定義するalwaysAllow関数が次のハンドラーに処理を渡します。

const alwaysAllow = (_1, _2, next) => {
  next();
};
const protect = (req, res, next) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    res.sendStatus(401);
  } else {
    next();
  }
};

古いサーバー技術の中には、HTTPヘッダーに機能しないサーバー説明データを含めるものもあります。APIのセキュリティを保つため、悪意ある攻撃者に与える情報を減らせるよう、この情報を削除します。

app.disable("x-powered-by");

app.use(helmet());

app.use(responseTime());

app.use(
  expressWinston.logger({
    transports: [new winston.transports.Console()],
    format: winston.format.json(),
    statusLevels: true,
    meta: false,
    level: "debug",
    msg: "HTTP {{req.method}} {{req.url}} {{res.statusCode}} {{res.responseTime}}ms",
    expressFormat: true,
    ignoreRoute() {
      return false;
    },
  })
);

app.use(cors());

app.use(rateLimit(config.rate));

app.use(
  session({
    secret,
    resave: false,
    saveUninitialized: true,
    store,
  })
);

app.get("/login", (req, res) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    req.session.authenticated = true;
    res.send("Successfully authenticated");
  } else {
    res.send("Already authenticated");
  }
});

次に、config.jsに記載されたプロキシを順に処理し、それぞれのprotectedパラメーターの値を確認します。先ほど定義したprotect関数またはalwaysAllow関数を呼び出し、設定された項目ごとに新しいプロキシを追加します。

Object.keys(config.proxies).forEach((path) => {
  const { protected, ...options } = config.proxies[path];
  const check = protected ? protect : alwaysAllow;
  app.use(path, check, createProxyMiddleware(options));
});

app.get("/logout", protect, (req, res) => {
  req.session.destroy(() => {
    res.send("Successfully logged out");
  });
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

このコードはすでに柔軟性がありますが、HTTPSではなく標準のHTTPを使用しています。多くの場合、これで十分でしょう。たとえば、Kubernetesではコードの前段にNGINX ingressを置き、TLS処理と証明書管理を任せることができます。

ただし、実行中のコードをインターネットに直接公開したい場合もあります。その場合は、有効な証明書が必要です。

Let’s Encryptのような認証局では、HTTPSを有効にする方法がいくつも用意されています。Greenlock Expressのようなクライアントを使って証明書を自動管理することも、Publishlab acme-clientのようなクライアントを使って、より細かく制御することもできます。

const { readFileSync } = require('fs');
const { createServer } = require('https');

// assumes that the key and certificate are stored in a "cert" directory
const credentials = {
  key: readFileSync('cert/server.key', 'utf8'),
  cert: readFileSync('cert/server.crt', 'utf8'),
};

// here we use the express "app" to attach to the created server
const httpsServer = createServer(credentials, app);

sudoが必要ですが、ここではサーバーを直接公開する場合のみHTTPSを使うため、sudoの使用は前提としています。そのため、8443などのパブリックポートではなく、443を使う必要があります。

// use standard HTTPS port
httpsServer.listen(443);

まとめ

Node.jsを使って、セキュアなAPIゲートウェイをゼロから構築しました。セッション管理、スロットリング、プロキシ、ログ記録、CORSなどの機能を実装しました。また、TLSを使用するようNode.jsを設定し、HTTPSでのアクセスを必須にする方法も学びました。

APIゲートウェイの導入は、大規模なバックエンドインフラに大きなメリットをもたらします。バックエンドサービスをAPIゲートウェイの背後に隠すことで、共通のセキュリティプロトコルを簡単に適用できるようになり、サービスを大幅にシンプル化するとともに、日常的に発生する脆弱性から保護できます。

カテゴリー:

続きを読む

Live Stream

Remediation Agentsの謎を解く:脆弱性の発見より修正が重要な理由

SnykのRemediation Agentが、セキュリティインテリジェンス、修正による影響分析、検証を活用して、脆弱性をマージ可能なプルリクエストに変える方法をご紹介します。

feature ai ide dark
Blog

Evo Agentic AppSecの初公開:エージェントによる修正と悪意あるコードへの防御

Snyk初のAgentic AppSec機能をご紹介します。脆弱性を自律的に修正するRemediation Agentと、リスクのあるパッケージがリリースされる前に阻止するMalicious Code Defenseです。

feature insights context
Blog

keyv npm侵害の実態:preinstallマルウェア、信頼された来歴、IDEフック

keyv 6.0.0と関連するnpmリリース10件に、インストール時に実行されるマルウェアが含まれていました。影響を受けるバージョン、ハッシュ、検出手順、安全な修復の順序を解説します。