Next.jsのAIサービスで有害コンテンツのアップロードをブロックする
隔離アップロード構造を使えば、AIサービスで有害コンテンツをアップロードから検証まで、サーバーを経由せずに安全に処理する方法を段階的に紹介します。要点は、事前検証前のファイル露出の遮断、1回限りのアップロードURL、判定APIのスコアに基づく処理、そして動画やプロンプトまで含めた包括的な防御体制です。
目次
ユーザーがファイルをアップロードできるAIサービスでは、リリース初期からアダルトコンテンツ(有害コンテンツ)の投稿を遮断する先制的な仕組みを必ず用意する必要があります。公開されたファイルはキャッシュやキャプチャなどを通じて拡散しやすいため、事後の削除だけでは対応に限界があります。
本記事では、Next.jsサービス環境に適用できる「隔離アップロード(Isolated Upload)」構造を段階的に整理します。要点は、事前検証が完了するまではどの経路からもファイルが外部に露出しないよう制御することです。コード例はAWS S3とOpenAI Moderation APIを前提に書いていますが、他のストレージや判定APIを使っても同じアーキテクチャで実装できます。
mindmap
root(("브라우저"))
n1("격리버킷")
n2("서버 검사")
n3("공개버킷")
n4("사람검토")
n5("삭제")
隔離アップロードの構成
隔離アップロード方式は、バケットを二つに分けることから始まります。隔離バケットは外部からアクセスできないプライベートな状態に保ち、公開バケットだけをCDNと連携させます。このとき、ユーザーは隔離バケットにしかファイルをアップロードできません。
ファイル転送時はNext.jsサーバーを経由しない構成にします。サーバーは1回限りのアップロードURL(Presigned URL)を生成して渡し、ブラウザがそのURLを使ってストレージへ直接アップロードします。これによりサーバーの負荷を抑えられ、容量制限の問題も効率よく解決できます。
// app/api/upload-url/route.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ region: "ap-northeast-2" });
export async function POST(req: Request) {
const user = await requireUser(req); // 로그인 확인
const { contentType, size } = await req.json();
if (!ALLOWED_TYPES.includes(contentType)) return new Response(null, { status: 415 });
if (size > MAX_BYTES) return new Response(null, { status: 413 });
const key = `quarantine/${crypto.randomUUID()}`;
const url = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: process.env.QUARANTINE_BUCKET,
Key: key,
ContentType: contentType,
}),
{ expiresIn: 60 } // 60초 뒤 만료
);
await db.upload.create({ data: { key, userId: user.id, status: "pending" } });
return Response.json({ url, key });
}この作業を行う際に守るべき4つの要点を以下のとおり共有します。
1つ目、ファイル名はセキュリティのためユーザー入力を使わず、サーバー側でランダムに生成して管理します。
2つ目、データベースには「pending」状態で記録し、ユーザー画面には「確認中」と表示します。
3つ目、ファイルアップロードURLの有効時間は最小限にして運用します。
4つ目、ファイル形式は拡張子ではなく実際のコンテンツに基づいて検証します。拡張子やContent-Typeは改ざんされる可能性があるため、信頼しません。
サーバー側の処理
検査は必ずサーバーで行います。ブラウザで実行する検査は、リクエストを直接送るだけで回避されてしまいます。アップロードが終わると、ブラウザが完了APIを呼び出し、サーバーが隔離バケットのファイルを判定APIに渡します。このとき判定APIがファイルを読めるよう、有効時間の短い読み取り専用URLを作って渡します。
// app/api/upload-complete/route.ts
import OpenAI from "openai";
import { GetObjectCommand } from "@aws-sdk/client-s3";
const openai = new OpenAI();
export async function POST(req: Request) {
const user = await requireUser(req);
const { key } = await req.json();
const upload = await db.upload.findFirst({ where: { key, userId: user.id } });
if (!upload || upload.status !== "pending") return new Response(null, { status: 404 });
const readUrl = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: process.env.QUARANTINE_BUCKET, Key: key }),
{ expiresIn: 120 }
);
const res = await openai.moderations.create({
model: "omni-moderation-latest",
input: [{ type: "image_url", image_url: { url: readUrl } }],
});
const score = res.results[0].category_scores.sexual;
const status = await decide(key, score); // 3단계에서 설명
return Response.json({ status });
}画像の処理はリクエスト内ですぐに実行できます。動画のように処理時間が長い作業は、サーバーレス関数の実行時間制限があるため、キューに入れて別のワーカーで処理する設計にしてください。
また、ブラウザが完了APIを呼ばずに離脱する可能性にも備える必要があります。一定時間以上「pending」のままのファイルは定期的に整理するか、ストレージのアップロードイベントをトリガーに検査を開始するように実装して、処理の抜け漏れを防いでください。
スコア処理
判定APIは成人向けコンテンツかどうかを二択で分けず、0から1の間のスコアを返します。そして二つの基準線を設定し、三つの区間に分類します。
const BLOCK = 0.8; // 이 이상이면 자동 차단
const REVIEW = 0.3; // 이 이상이면 사람 검토
async function decide(key: string, score: number) {
if (score >= BLOCK) {
await s3.send(new DeleteObjectCommand({ Bucket: QUARANTINE, Key: key }));
return setStatus(key, "rejected", score);
}
if (score >= REVIEW) {
return setStatus(key, "review", score); // 격리 버킷에 그대로 둠
}
const publicKey = key.replace("quarantine/", "public/");
await s3.send(new CopyObjectCommand({
Bucket: PUBLIC,
Key: publicKey,
CopySource: `${QUARANTINE}/${key}`,
}));
await s3.send(new DeleteObjectCommand({ Bucket: QUARANTINE, Key: key }));
return setStatus(key, "approved", score);
}0.8と0.3は例示の値です。実際のサービスに投稿されそうなサンプルを集めて実行し、見逃した割合と防げた割合を確認しながら調整してください。最も重要なルールは、エラーが起きた場合に通過させないことです。判定APIが応答しない、またはエラーになった場合、ファイルはpendingのまま残り、再試行の対象になります。障害時に検査を飛ばす仕組みは危険です。
動画、プロンプト、AI生成物
画像以外の経路も同じ原則で防ぎます。動画はffmpegで1〜2秒間隔のフレームを抽出し、それぞれを画像と同じように検査します。一枚でも遮断線を超えたら、動画全体を拒否します。フレーム数がそのままコストになるため、アップロードできる動画の長さに上限を設けてください。
テキストのプロンプトは、生成リクエストを処理する前にテキスト検査を通します。同じ判定APIに文字列を渡すだけで実装できます。AIが生成した結果も、ユーザーに表示する前に2段階目と3段階目と同じ検査を行います。入力だけを防ぐと言い換え表現で突破されるため、実際には出力の検査のほうが頼りになる防衛線です。
判定APIとコスト
最初は無料のOpenAI Moderationで始め、動画や細かい分類が必要になった時点で有料サービスに移るのが、最も経済的な順序です。以下の価格は2026年10月7日時点で確認した値で、単位は米ドルです。
| サービス | 対応形式 | 価格 | 画像 月1万枚 |
|---|---|---|---|
| OpenAI Moderation | テキスト、画像 | 無料 | $0 |
| Azure AI Content Safety | テキスト、画像 | 画像1,000枚あたり$0.75、テキスト1,000件あたり$0.38 | 約$7.5 |
| AWS Rekognition | 画像、動画 | 画像1枚あたり$0.001、動画1分あたり$0.10 | 約$10 |
| Google Cloud Vision SafeSearch | 画像 | 毎月最初の1,000枚は無料、以降1,000枚ごとに$1.50 | 約$13.5 |
| Sightengine | テキスト、画像、動画 | 無料枠は月2,000件、Starter は月$29で1万件、超過分は1件あたり$0.002 | $29 |
| Hive Moderation | テキスト、画像、動画、音声 | 画像1,000件あたり$3.00、テキスト1,000件あたり$1.50 | 約$30 |
OpenAIは無料ですが呼び出し上限があり、一般的な有害性分類のため露出の度合いを細かく分けることはできません。また、動画はフレームを自分で抽出して送る必要があります。Sightengineは一つの画像に複数の判定モデルを使うと、モデル数の分だけ件数が消費されます。動画は基本的に毎秒1フレームで処理されるため、件数がすぐに尽きてしまいます。AWSの動画専用APIは、フレーム抽出方式と比べてコスト面で有利とはいえません。60秒の動画の場合、動画APIのコストは$0.10ですが、画像APIに毎秒2フレームを送ると$0.12になります。
事後の仕組みと法的義務
自動検査は完璧ではないため、通過した後にも取り除ける仕組みを用意することをおすすめします。通報ボタンはすべてのコンテンツに付け、通報が積み重なったら自動的に非公開へ切り替えて再審査します。アカウントへの措置では、違反回数をアカウントに記録し、繰り返される場合はアップロードを制限するか利用停止にします。判定ログには、スコア、時刻、処理結果を残してください。基準線を調整するときや紛争が起きたときの根拠になります。再アップロードの遮断では、拒否したファイルのハッシュを保存し、同じファイルが再び上がってきたら即座にブロックしてください。
極めて重要な事案
児童性的搾取物は、一般のアダルトコンテンツとは異なる扱いが必要です。疑わしいファイルは通常の審査待ちキューに入れず、すぐに遮断してください。保管、通報、削除の手続きには法的義務が伴うため、リリース前に専門家の確認を受けるのが安全です。
韓国では、一定規模以上の事業者に対して、不法撮影物のフィルタリングなどの技術的措置を講じる義務があります。自分のサービスが対象かどうかは利用者数と売上の基準で決まるため、これもリリース前に確認すべき項目です。
コメント 0
- コメントを読み込んでいます。