Flatt Security Blog

株式会社Flatt Securityの公式ブログです。プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

株式会社Flatt Securityの公式ブログです。
プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

オブジェクトストレージにおけるファイルアップロードセキュリティ - クラウド時代に"悪意のあるデータの書き込み"を再考する

はじめに

セキュリティエンジニアの齋藤ことazaraです。今回は、オブジェクトストレージに対する書き込みに関連するセキュリティリスクの理解と対策についてお話しします。

本ブログは、2024年3月30日に開催された BSides Tokyo で登壇した際の発表について、まとめたものです。 また、ブログ資料化にあたりオブジェクトストレージを主題とした内容の再編と、登壇時に口頭で補足した内容の追記、必要に応じた補足を行なっています。

なぜ今、この問題を取り上げるのか?

近年のクラウドリフト、クラウドシフトにより、クラウドを活用する場面が多くなってきていると思います。その中で、多くの場面で利用されるオブジェクトストレージにおいて、データの書き込み時に気にすべきセキュリティリスクが存在するのをご存知でしょうか?

近年、オブジェクトストレージの不適切な利用に起因する情報漏洩が多く発生しています。そのような事例がニュース等で度々取り上げられていることから、オブジェクトストレージにおけるデータの読み取りに関するセキュリティリスクについてご存じの方は増えてきていると考えられます。

しかし、書き込みに関するセキュリティリスクについては、"改ざん"という点では一定の理解があるものの、"悪意のあるデータの書き込み"に関しては、意識が低いのが現状だと考えています。

本ブログでは、"悪意のあるデータの書き込み"、その中でもプロダクトの利用者に被害を及ぼすケースについて、理解を深めることを目的としています。

概要

オブジェクトストレージにおけるオブジェクトのアップロード方法は複数あります。その中で、ある設定値が抜けていることが原因で、悪意のあるオブジェクトがアップロードされることを許容してしまう可能性があります。

例えば、AWS SDK for JavaScript v3で、署名付き URL を発行する際に利用する@aws-sdk/s3-request-presignergetSignedUrlの設定ミスに関するリスクについてご存知でしょうか?

getSignedUrlでは、署名付き URL を生成する際に、expiresInsignedHeadersなどのオプションを指定することができます。このオプションによって、生成される署名付き URL の有効期限や、署名に含めるヘッダーを指定することができます。

このようにオプションを含めて署名付き URL を生成する際に、signedHeadersContent-TypeContent-Dispositionなどのヘッダーを指定しない場合、アップロードされるファイルのContent-TypeContent-Dispositionが、PutObjectCommandの引数に指定された値と異なることを許容してしまいます。

この場合、悪意のあるユーザーは、アップロード時に指定された値と異なるContent-TypeContent-Dispositionを設定する、または異なる値になるようにヘッダーの値を改ざんすることで、オブジェクトストレージに対して悪意のあるオブジェクトをアップロードできます

このリスクの原因は、SDK及び公式ドキュメントなどで例示されている実装を参照したとしても、getSignedUrlが開発者にとって自明ではない動作をすることにあると考えています。

本ブログでは、知っていそうで、知らない、オブジェクトストレージにおいてよくあるアップロード処理の実装ミスを取り上げ、そのリスクと対策について解説します。

オブジェクトストレージの特性への再入門

そもそも、オブジェクトストレージにおけるオブジェクトとは何か、オブジェクトストレージの特性とは何か、改めて確認しておきましょう。

オブジェクトストレージは、ファイルシステムとは異なり階層構造を持たず、保存対象のデータを単一のオブジェクトとして扱います。このオブジェクトは、オブジェクトに関するメタ情報が含まれたメタデータとデータ本体から構成されています。

このメタデータには、オブジェクトの Content-Type や Content-Disposition などの情報を付与することができます。この情報は、オブジェクトの取得時にレスポンスヘッダーとして返却され、ブラウザやクライアントアプリケーションにおいて、そのオブジェクトの種類を判別するために利用されます。

メタデータ自体は、データのアップロードを行う際に、HTTP ヘッダーとして付与することができ、その値を含めてオブジェクトとして保存されます。

設定可能なメタデータ

次に、オブジェクトストレージで設定可能なメタデータについて見ていきましょう。 本ブログでは S3 を例にしつつも、どのオブジェクトストレージでも共通して設定可能なメタデータについて解説します。

メタデータ 説明
Content-Type ファイルの MIME タイプを指定するためのメタデータ
Content-Disposition ファイルのダウンロード時のファイル名を指定するためのメタデータ
Cache-Control ファイルのキャッシュ制御を行うためのメタデータ
Storage-Class オブジェクトの保存クラスを指定するためのメタデータ

オブジェクトストレージにおけるファイルのアップロード方法

オブジェクトストレージにおけるファイルのアップロード方法には、主に以下の 3 つの方法があります。

  1. サーバーサイドからのアップロード
  2. クライアントサイドからのアップロード(署名付き URL)
  3. クライアントサイドからのアップロード(Post Policy)

これらの方法は、それぞれの特性によって、利用されるシーンが異なります。それぞれの特性について、理解を深めていきましょう。

1. サーバーサイドからのアップロード

サーバーサイドからのアップロードは、サーバーサイドでファイルを受け取り、オブジェクトストレージにアップロードする方法です。この方法は、サーバーサイドでファイルの内容を検証し、必要に応じて加工を行うことができるため、セキュリティリスクを抑えることができます。一方で、サーバーサイドでの処理が必要となるため、サーバーのリソースを消費することがデメリットとして挙げられます。

主なデータの流れとしては、まず、クライアントからファイルをアップロードするリクエストがサーバーに送信されます。サーバーは、ファイルを受け取り、必要に応じて検証や加工を行った後、ファイルをオブジェクトストレージにアップロードします。その後、クライアントに対して、アップロードの結果を返却します。

// Sample Code (Node.js)
import fastify from 'fastify';
import { fastifyMultipart } from '@fastify/multipart';

import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';

const server = fastify();
server.register(fastifyMultipart);

server.post('/api/upload', async (request, reply) => {
  const data = await request.file({
    limits: {
      fileSize: 1024 * 1024 * 100,
      files: 1,
    },
  });
  if (!data) {
    return reply.code(400).send({ error: 'No file uploaded' });
  }

  if (!data.file) {
    return reply.code(400).send({ error: 'Invalid file' });
  }

  const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif'];
  if (!data.mimetype || !allowedMimeTypes.includes(data.mimetype)) {
    return reply.code(400).send({ error: 'Invalid mimetype' });
  }

  const filename = uuidv4();
  const s3 = new S3Client({});
  const command = new PutObjectCommand({
    Bucket: process.env.BUCKET_NAME,
    Key: `upload/${filename}`,
    Body: data.file,
    ContentLength: data.file.bytesRead,
    ContentType: data.mimetype,
  });

  await s3.send(command);
  reply.send(`/upload/${filename}`);
  return reply;
});

2. クライアントサイドからのアップロード(署名付き URL)

クライアントサイドからのアップロード(署名付き URL)は、サーバーサイドのアプリケーションが署名付き URL を生成し、その URL を用いてクライアントが直接オブジェクトストレージにファイルをアップロードする方法です。この方法は、サーバーサイドでの処理が不要となるため、サーバーのリソースを消費することがないというメリットがあります。一方で、サーバーサイドからのアップロードに比べ柔軟な検証や加工が行えないことがデメリットとして挙げられます。

主なデータの流れとしては、まず、サーバーサイドで署名付き URL を生成し、クライアントに返却します。クライアントは、この署名付き URL を用いて、直接オブジェクトストレージにファイルをアップロードします。その後、オブジェクトストレージからクライアントに対して、アップロードの結果が返却されます。

この、クライアントサイドからのアップロード(署名付き URL)において、署名付き URL の生成には、以下のようなコードが利用され、サーバーサイドが想定したリクエストと同一のリクエストが送信されることが期待されます。もし、「署名付き URL の生成時にサーバーサイドが想定したリクエスト」と異なるリクエストがクライアント側から送信されることがあれば、オブジェクトストレージ側での署名検証時に検知することが可能です。

// Sample Code (Node.js)
import fastify from 'fastify';

import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const server = fastify();

server.post<{
  Body: {
    contentType: string;
    length: number;
  };
}>('/api/upload', async (request, reply) => {
  if (!request.body.contentType || !request.body.length) {
    return reply.code(400).send({ error: 'No file uploaded' });
  }

  if (request.body.length > 1024 * 1024 * 100) {
    return reply.code(400).send({ error: 'File too large' });
  }

  const allow = ['image/png', 'image/jpeg', 'image/gif'];
  if (!allow.includes(request.body.contentType)) {
    return reply.code(400).send({ error: 'Invalid file type' });
  }

  const filename = uuidv4();
  const s3 = new S3Client({});
  const command = new PutObjectCommand({
    Bucket: process.env.BUCKET_NAME,
    Key: `upload/${filename}`,
    ContentLength: request.body.length,
    ContentType: request.body.contentType,
    ContentDisposition: 'attachment',
    StorageClass: 'STANDARD',
  });

  const url = await getSignedUrl(s3, command, {
    expiresIn: 60 * 60 * 24,
    signedHeaders: new Set(['content-type', 'content-length', 'content-disposition', 'x-amz-storage-class']),
  });
  return reply.header('content-type', 'application/json').send({
    url,
    filename,
  });
});

署名時のアルゴリズムにはAWS4-HMAC-SHA256が用いられており、APIに送信される各要素を署名し、受信した値が想定通りに署名されたものかを検証します。

署名される要素 HTTP Method URI Query HeaderとHeaderの値のペア 署名に用いられるHeaderの名前 BodyなどのPayload

先のサンプルコードでは、getSignedUrlを用いて署名を行う際に、署名時に含めるヘッダーとして、content-typecontent-lengthcontent-dispositionx-amz-storage-classが指定されています。

この場合、クライアントから送信されるリクエストがサーバーが想定したリクエストと同一のものかを検証するためにも用いられます。

この署名方式における特徴として以下の点が挙げられます。

  1. Signed Headersに明示されているヘッダーはリクエスト時の署名検証が行われる
  2. Signed Headersに含まれないヘッダーはリクエスト時の署名検証が行われない
  3. 署名検証が行われないヘッダーは設定されていて、かつ利用可能な場合、その値をAmazon S3のAPIで解釈する

このうち、2と3に関しては、後述するメタデータの値の変更に用いられるヘッダーに関しても適用されるという副作用があるため、注意が必要です。

3. クライアントサイドからのアップロード(Post Policy)

Post Policy は、先の署名付き URL と似ているものの、アップロードされようとしているデータや付与されるメタデータがポリシーに基づいているかどうかを検証する方法です。

この方法において、まず、サーバーサイドで生成されたポリシーと、関連する Fields を含むフォームをクライアントに返却します。その後、クライアントは、これらの情報を用いてフォームを生成し、オブジェクトストレージにファイルをアップロードします。この方法は、署名付き URL に比べ、Content-type の指定やファイルサイズの制限などを柔軟に設定することができるというメリットがあります。

詳しい内容は以下のブログにて詳しく解説されています。

メタデータの値を変更することで発生するリスク

ここまでは、オブジェクトストレージへの3種類のアップロード方法について説明をしました。

本章では、アップロードを行う利用者がオブジェクトストレージのメタデータを変更することで発生するリスクについて、理解を深めていきたいと思います。

観点1: Content-Type の変更による XSS

Content-Type は、ファイルの MIME タイプを指定するためのメタデータです。この値は、ファイルの取得時にレスポンスヘッダーとして返却され、ブラウザやクライアントアプリケーションにおいて、そのファイルの種類を判別するために利用されます。すなわち、レンダリングを行う際の処理に影響を与えます。

例えば、text/html という Content-Type を指定した場合、ブラウザは、そのファイルを HTML として解釈し、レンダリングを行います。

この Content-Type を任意の値に設定することが可能な場合、アップロードの際に、例えば text/html という Content-Type を指定することができます。するとブラウザに対して、そのファイルを HTML として解釈させ、XSS を引き起こすことができます。

観点2: Content-Disposition の変更による Reflected File Download

Content-Disposition は、ファイルのダウンロード時のファイル名を指定するためのメタデータです。

例えば下記のようなレスポンスが返却された場合、ブラウザはexample.txt というファイル名でダウンロードを行います。

HTTP/1.1 200 OK
Content-Disposition: attachment; filename="example.txt"
Content-Type: text/plain

Hello, World!
Example File.

この Content-Disposition の値を任意の値に設定することが可能な場合、アップロードの際に、例えば attachment; filename="example.exe" という Content-Disposition を指定することができます。するとブラウザに対して、そのファイルを example.exe としてダウンロードさせることが可能であり、Reflected File Download が引き起こされます。

Reflected File Download は、信頼できるドメインから任意のコンテンツを強制的にダウンロードさせる攻撃手法です。ユーザーはダウンロード元のドメインを信用しているので、比較的高い確率でユーザーのホスト上で悪意のあるコードを実行させることができます。

観点3: Storage-Classを変更することによる EDoS

Storage-Class は、オブジェクトの保存クラスを指定するためのメタデータです。例えば、STANDARDINTELLIGENT_TIERINGONEZONE_IAGLACIER などの保存クラスを指定することが可能です。

関連するリスクとして、一部のStorage-Classを指定することで、オブジェクトの読み取り時に高額な利用料金が発生することがあります。 例えば、GLACIER というStorage-Classを指定することで、悪意のあるユーザーは、オブジェクトストレージの利用者(サービス提供者)に対して、高額な利用料金を発生させる、すなわち Economic Denial of Sustainability (EDoS) を引き起こすことが可能となります。

EDoS に関しては、以下のブログにて詳しく解説しているので、参考にしていただければ幸いです。

リスクの詳細と対策

ここまで、メタデータの値を変更することによって発生するリスクについて触れてきました。 本ブログの締めくくりとして、実際にそのようなリスクが発生しうるのか、コードや仕組みのレベルまで掘り下げて検証し、対策について解説します。

リスク1: AWS SDKのドキュメンテーションされていない動きに起因するリスク

AWS などの SDK において、オブジェクトストレージにアップロードする際の署名を生成する際の前処理が異なる場合があり、これらを知らないで利用することで、利用者が先のリスクに直面する可能性があります。

事例: @aws-sdk/s3-request-presignerの挙動

AWS SDK for JavaScript V3 を利用すると、署名生成時に暗黙的に削除されるヘッダーが存在します。これはドキュメントに未記載の挙動であり、利用者がこの挙動を知らないまま利用することで、先のリスクが発生する可能性があります。

具体的には、@aws-sdk/s3-request-presignergetSignedUrlでは、署名付き URL を生成する際に署名に含めるヘッダーを指定しない場合、content-typeヘッダーが削除されてしまいます。

// Example Code (JavaScript V3 SDK)
const filename = uuidv4();
const s3 = new S3Client({});
const command = new PutObjectCommand({
  Bucket: process.env.BUCKET_NAME,
  Key: `upload/${filename}`,
  ContentLength: request.body.length,
  ContentType: request.body.contentType,
  ContentDisposition: 'attachment',
  StorageClass: 'STANDARD',
});

const url = await getSignedUrl(s3, command, {
  expiresIn: 60 * 60 * 24,
});

このような事象は、SDK における署名生成時のリクエストの処理に起因するものです。SDK の実装を見てみるとprepareRequestメソッドにおいて、unsignableHeadersとしてcontent-typeが指定されています。この場合、content-typeヘッダーは、署名生成時に削除されてしまいます。

// https://github.com/aws/aws-sdk-js-v3/blob/885b47ecd94981e372fc4cd673a2b4abddaaed39/packages/s3-request-presigner/src/presigner.ts#L60-L79
export class S3RequestPresigner implements RequestPresigner {
  // Snip
  private prepareRequest(requestToSign: IHttpRequest, { unsignableHeaders = new Set(), unhoistableHeaders = new Set() }: RequestPresigningArguments = {}) {
    unsignableHeaders.add('content-type');
    Object.keys(requestToSign.headers)
      .map((header) => header.toLowerCase())
      .filter((header) => header.startsWith('x-amz-server-side-encryption'))
      .forEach((header) => {
        unhoistableHeaders.add(header);
      });
    requestToSign.headers[SHA256_HEADER] = UNSIGNED_PAYLOAD;

    const currentHostHeader = requestToSign.headers.host;
    const port = requestToSign.port;
    const expectedHostHeader = `${requestToSign.hostname}${requestToSign.port != null ? ':' + port : ''}`;
    if (!currentHostHeader || (currentHostHeader === requestToSign.hostname && requestToSign.port != null)) {
      requestToSign.headers.host = expectedHostHeader;
    }
  }
}

この事象への対策として、getSignedUrlを用いる場合は、署名対象のヘッダーを設定するsignedHeaderscontent-typeヘッダーを含めるべきです。

getSignedUrlsignedHeadersに明示的にヘッダーが指定された場合、unsignableHeadersに含まれるヘッダーも署名生成時に署名要素として含めてくれます。

下記が実装例になります。

const url = await getSignedUrl(s3, command, {
  expiresIn: 60 * 60 * 24,
  signedHeaders: new Set(['content-type']),
});

事例: AWS SDK for Go の挙動

AWS SDK for Goでは、署名付きURLを生成する際に、Content-Typeを含めて署名を行うためには、Content-Lengthに1以上の値が設定されている必要があります。

そのため、下記のようなコードの場合、Content-Typeは署名時のCanonical Headerに含まれません。

package main

import (
    "context"
    "log"
    "os"
    "time"

    "github.com/aws/aws-sdk-go-v2/aws"
    v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/s3"
)

type Presigner struct {
    PresignClient *s3.PresignClient
}

func (presigner Presigner) PutObject(
    bucketName string, objectKey string, lifetimeSecs int64) (*v4.PresignedHTTPRequest, error) {
    request, err := presigner.PresignClient.PresignPutObject(context.TODO(), &s3.PutObjectInput{
        Bucket:      aws.String(bucketName),
        Key:         aws.String(objectKey),
        ContentType: aws.String("application/octet-stream"),
    }, func(opts *s3.PresignOptions) {
        opts.Expires = time.Duration(lifetimeSecs * int64(time.Second))
    })
    if err != nil {
        log.Printf("Couldn't get a presigned request to put %v:%v. Here's why: %v\n",
            bucketName, objectKey, err)
    }
    return request, err
}

func main() {
    cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithSharedConfigProfile("default"))
    client := s3.NewFromConfig(cfg)
    presignClient := s3.NewPresignClient(client)
    presigner := Presigner{PresignClient: presignClient}
    BUCKET_NAME := os.Getenv("BUCKET_NAME")
    request, err := presigner.PutObject(BUCKET_NAME, "example-object", 60)
    if err != nil {
        log.Fatalf("Couldn't get a presigned request. Here's why: %v\n", err)
    }
    log.Printf("Presigned URL: %v\n", request.URL)
}

AWS SDK for Goで署名付きURLを生成する際は、クライアントから明示的にアップロードするファイルのSizeを指定する必要があります。

リスク2: 署名生成時のメタデータに関連するヘッダーの未設定

先のメタデータの値によって発生するリスクでも述べたように、特定のメタデータが設定されることで、クライアントやメタデータの値を活用するシステムで作用を起こすことがあります。

このようなリスクへの対策として、アップロード時にユーザーの入力を用いてメタデータの値の設定を行わない、または、設定可能な値を制限することが有効です。

例えば、署名を生成する際にsignedHeadersにおいて署名に含めるヘッダーを指定することで、ユーザーが任意のヘッダー書き換えることを禁止できます。

const url = await getSignedUrl(s3, command, {
  expiresIn: 60 * 60 * 24,
  signedHeaders: new Set(['content-type', 'content-length', 'content-disposition', 'x-amz-storage-class']),
});

これらのヘッダーがsignedHeadersに指定されなかった場合、先のクライアントサイドからのアップロード(署名付き URL)で述べたように、ユーザーが任意の値を設定可能になり、先に述べたXSSなどのリスクが発生します。

リスク3: 署名時やポリシー生成時に用いる値の検証不備

署名付きURLやPOST Policyの生成時に、ユーザーの入力をそのまま利用するとリスクが生じます。

例えば、署名付き URL の生成について下記のコードのような実装をしていたとします。一見、signedHeaderscontent-typeを含めることで、content-typeヘッダーの改ざんなどを防ぐことができているように見えます。しかし実際にはcontent-typeヘッダーの値をユーザーの入力から取得しているため、ユーザーが任意の値を設定でき、XSS のリスクが生じます。

// Sample Code (Node.js)
import fastify from 'fastify';

import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const server = fastify();

server.post<{
  Body: {
    contentType: string;
    length: number;
  };
}>('/api/upload', async (request, reply) => {
  if (!request.body.contentType || !request.body.length) {
    return reply.code(400).send({ error: 'No file uploaded' });
  }

  if (request.body.length > 1024 * 1024 * 100) {
    return reply.code(400).send({ error: 'File too large' });
  }

  const filename = uuidv4();
  const s3 = new S3Client({});
  const command = new PutObjectCommand({
    Bucket: process.env.BUCKET_NAME,
    Key: `upload/${filename}`,
    ContentLength: request.body.length,
    ContentType: request.body.contentType,
    ContentDisposition: 'attachment',
    StorageClass: 'STANDARD',
  });

  const url = await getSignedUrl(s3, command, {
    expiresIn: 60 * 60 * 24,
    signedHeaders: new Set(['content-type', 'content-length', 'content-disposition', 'x-amz-storage-class']),
  });
  return reply.header('content-type', 'application/json').send({
    url,
    filename,
  });
});

先に例示したコードでは、request.body.contentTypeのようなユーザーから入力された値を検証せずにそのまま利用してます。そのため、ユーザーが任意の値を設定することが可能です。

例示したコードのような例以外にも、不完全な検証(e.g. 正規表現やstartsWithendsWithなどの検証)が原因で、悪意のあるユーザーが任意の値を設定でき、Content-Typeに起因するXSSなどが引き起こされます。

この事象に関しては、別のブログとして「Content-Type の不可思議な動きと、クラウド時代でのセキュリティリスク」を公開する予定なので、そちらをご確認ください。

リスク4: 不完全なポリシーの設定

オブジェクトストレージに Post Policy を利用してアップロードする際に、ポリシーの設定が不完全である場合、悪意のあるユーザーが任意のメタデータの値を設定でき、Content-Typeに起因するXSSなどのリスクが発生します。

Post Policy にはstarts-withと呼ばれる属性があります。この属性を用いることで、特定の文字列で始まる値だけを設定できるようになり、アップロードの制約を柔軟にかけることができます。

一方で、設定する制約が不完全だとリスクに繋がります。例えば、Content-Typeの検証を行う際に、先頭の文字列がimageで設定されているとします。そのような設定では、悪意のあるユーザーは、imageで始まる任意の値を設定するなどの方法で、ブラウザにMimeTypeを誤って解釈させることができます。結果、レスポンスの内容がHTMLとして解釈された場合、XSSのリスクが発生します。

import fastify from 'fastify';

import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const server = fastify();

server.post<{
  Body: {
    contentType: string;
    length: number;
  };
}>('/api/upload', async (request, reply) => {
  if (!request.body.contentType || !request.body.length) {
    return reply.code(400).send({ error: 'No file uploaded' });
  }

  if (request.body.length > 1024 * 1024 * 100) {
    return reply.code(400).send({ error: 'File too large' });
  }

  const filename = uuidv4();
  const s3 = new S3Client({});
  const { url, fields } = await createPresignedPost(s3, {
    Bucket: process.env.BUCKET_NAME!,
    Key: `upload/${filename}`,
    Conditions: [
      ['content-length-range', 0, 1024 * 1024 * 100],
      ['starts-with', '$Content-Type', 'image'],
    ],
    Fields: {
      'Content-Type': request.body.contentType,
    },
    Expires: 600,
  });
  return reply.header('content-type', 'application/json').send({
    url,
    fields,
  });
});

対策として、Content-Typestarts-withで設定する際に、MimeType における type のみを指定するのではなく、区切り文字である/を含めることが有効です。

const { url, fields } = await createPresignedPost(s3, {
  Bucket: process.env.BUCKET_NAME!,
  Key: `upload/${filename}`,
  Conditions: [
    ['content-length-range', 0, 1024 * 1024 * 100],
    ['starts-with', '$Content-Type', 'image/'],
  ],
  Fields: {
    'Content-Type': request.body.contentType,
  },
  Expires: 600,
});

まとめ

本ブログでははじめに、オブジェクトストレージにおけるファイルのアップロード方法およびメタデータについて解説し、次にメタデータにユーザー入力が発生した際のリスクについて解説しました。

オブジェクトストレージにおけるファイルのアップロード方法には、サーバーサイドからのアップロード、クライアントサイドからのアップロード(署名付き URL)、クライアントサイドからのアップロード(Post Policy)の 3 つの方法があり、それぞれの特性によって、利用されるシーンが異なります。

また、メタデータについては、Content-TypeContent-DispositionStorage-Classなどの値を変更することで、XSS、Reflected File Download、EDoS などのリスクが発生する可能性があります。これらのリスクを軽減するためには、ユーザーの入力を用いた値の設定を行わない、または、設定可能な値を制限することが重要です。

最後に、署名生成時の SDK の挙動に起因するリスクについて解説を行いました。特にリスク1では、JavaScript V3 SDK やAWS SDK for Goでの実装を例にSDKの実装に起因する署名生成の差異について解説しました。このSDKにおける署名生成の特性をよく確認しないまま開発者が利用するとサービス利用者にリスクが生じる可能性があります。

結論として、オブジェクトストレージにおけるファイルのアップロード方法とメタデータについて理解を深め、メタデータの値に起因して発生するリスクを把握し、それらのリスクを軽減するための対策を行うことが重要であると考えます。

参考文献

お知らせ

Flatt Security ではWebアプリケーションをはじめとする、様々なプロダクトへのセキュリティ診断サービスを提供しています。仕様・実装に不安のある方はぜひお気軽にお問い合わせください。

上記のデータが示すように、診断は幅広いご予算帯に応じて実施が可能です。ご興味のある方向けに下記バナーより料金に関する資料もダウンロード可能です。

また、Flatt Security はセキュリティに関する様々な発信を行っています。 最新情報を見逃さないよう、公式X のフォローをぜひお願いします!

では、ここまでお読みいただきありがとうございました。