Topic: blob storage security

MCP server blob storage security

MCP servers that wrap S3, GCS, or Azure Blob storage handle files on behalf of users — often across tenants in the same bucket. Missing ownership checks before presigned URL generation, public bucket ACLs, user-controlled object keys without path normalization, and gaps in encryption enforcement each create paths to cross-tenant data disclosure or data loss.

Presigned URL abuse and ownership verification

A presigned URL delegates S3 authentication to the URL itself: anyone who holds the URL can perform the signed operation within the TTL, regardless of their own AWS credentials. Three common vulnerabilities:

(a) Missing ownership check. The server generates a GET presigned URL for any object key the caller supplies, without verifying that the object belongs to the calling user. User A requests a presigned URL for uploads/user-B/private-doc.pdf and receives a valid URL they can download immediately.

// UNSAFE — no ownership check
app.get('/download/:key', async (req, res) => {
  const url = await s3.getSignedUrlPromise('getObject', {
    Bucket: process.env.BUCKET, Key: req.params.key, Expires: 86400,
  });
  res.json({ url });
});

// SAFE — verify ownership before signing
app.get('/download/:key', requireAuth, async (req, res) => {
  const tenantPrefix = `uploads/${req.user.tenant_id}/`;
  const normalizedKey = normalizePath(tenantPrefix, req.params.key);
  // normalizePath throws if key escapes the prefix (see section 3)
  const objectMeta = await db.objects.findByKey(normalizedKey);
  if (!objectMeta || objectMeta.owner_id !== req.user.id) {
    return res.status(403).json({ error: 'forbidden' });
  }
  const url = await getSignedUrl(s3Client, new GetObjectCommand({
    Bucket: process.env.BUCKET, Key: normalizedKey,
  }), { expiresIn: 300 }); // 5-minute TTL, not 24 hours
  res.json({ url });
});

(b) Excessive TTL. A 24-hour presigned URL stolen from a clipboard, log, or Referer header provides a full day of access. For download URLs, 300 seconds (5 minutes) is sufficient for the client to begin the transfer. For upload URLs, 600 seconds covers slow network conditions.

(c) PUT without Content-Type restriction. A presigned PUT URL without a Content-Type condition allows any file type to be uploaded, enabling stored XSS (upload an HTML file, serve it from the same origin) or malware hosting. Pin the Content-Type at signing time:

const url = await getSignedUrl(s3Client, new PutObjectCommand({
  Bucket: process.env.BUCKET,
  Key: normalizedKey,
  ContentType: allowedMimeType, // e.g. 'image/jpeg' — must match on upload
}), { expiresIn: 600 });

Public bucket misconfiguration: two-level Block Public Access

S3 has two independent layers of public-access control: account-level and per-bucket. A per-bucket Block Public Access setting can be overridden if the account-level setting is disabled. Both layers must be enabled. Apply the account-level setting once as an org-wide control:

aws s3api put-public-access-block \
  --account-id 123456789012 \
  --public-access-block-configuration \
    BlockPublicAcls=true,IgnorePublicAcls=true,\
    BlockPublicPolicy=true,RestrictPublicBuckets=true

Add a per-bucket policy that denies any GetObject not made over HTTPS, as a defence-in-depth layer against misconfiguration or accidental ACL grants:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyHTTPAccess",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::your-mcp-bucket/*",
      "Condition": {
        "Bool": { "aws:SecureTransport": "false" }
      }
    },
    {
      "Sid": "DenyPublicACL",
      "Effect": "Deny",
      "Principal": "*",
      "Action": ["s3:PutBucketAcl", "s3:PutObjectAcl"],
      "Resource": [
        "arn:aws:s3:::your-mcp-bucket",
        "arn:aws:s3:::your-mcp-bucket/*"
      ]
    }
  ]
}

Cross-tenant object path confusion via path traversal

MCP servers often construct object keys by concatenating a tenant prefix with a user-supplied filename. A ../ segment in the filename escapes the prefix and can reach another tenant's objects:

// UNSAFE: key = "uploads/tenant-A/../tenant-B/secret.pdf"
const key = `uploads/${tenantId}/${req.body.filename}`;

The fix is to resolve the composed key and verify it still starts with the tenant prefix after normalization. Node.js's path.posix.normalize collapses ../ segments:

import { posix } from 'node:path';

function normalizePath(prefix, userPath) {
  // Prefix must end with /
  const base = prefix.endsWith('/') ? prefix : prefix + '/';
  // Compose and normalize — posix.normalize collapses ../ segments
  const composed = posix.normalize(base + userPath);
  // Verify the normalized path still starts with the prefix
  if (!composed.startsWith(base)) {
    throw new Error(`Path traversal detected: ${userPath}`);
  }
  return composed;
}

// Usage
const key = normalizePath(`uploads/${req.user.tenant_id}/`, req.body.filename);
// "uploads/tenant-A/../../tenant-B/secret.pdf" throws
// "uploads/tenant-A/subdir/file.pdf" returns normalized path

Apply normalizePath to every object key derived from user input — not only file uploads but also presigned GET requests, copy operations, and delete requests.

Server-side encryption enforcement and audit log retention

Enforce SSE-KMS (or at minimum SSE-S3) on all writes via a bucket policy condition. Without enforcement, SDK callers can omit the encryption header and store objects in plaintext:

{
  "Sid": "DenyUnencryptedUploads",
  "Effect": "Deny",
  "Principal": "*",
  "Action": "s3:PutObject",
  "Resource": "arn:aws:s3:::your-mcp-bucket/*",
  "Condition": {
    "StringNotEquals": {
      "s3:x-amz-server-side-encryption": "aws:kms"
    }
  }
}

For audit logs stored in S3 — tool invocation records, access logs, security events — use S3 Object Lock in compliance mode to prevent deletion by anyone, including the bucket owner and IAM administrators. Compliance mode locks cannot be shortened or removed until the retention period expires:

aws s3api put-object-lock-configuration \
  --bucket your-mcp-audit-logs \
  --object-lock-configuration '{
    "ObjectLockEnabled": "Enabled",
    "Rule": {
      "DefaultRetention": {
        "Mode": "COMPLIANCE",
        "Days": 365
      }
    }
  }'

Compliance mode means even a compromised root account or disgruntled administrator cannot delete audit logs within the retention window — relevant for SOC 2 Type II and PCI-DSS requirements.

Multipart upload security: completing uploads without ownership re-verification

Large file uploads (video, audio, datasets used as MCP tool inputs) often use S3 multipart upload to avoid timeouts and enable resumable transfers. Multipart upload has its own security surface: the CreateMultipartUpload, UploadPart, and CompleteMultipartUpload operations each require separate authorization, and implementations that authorize only the first step can be exploited.

// UNSAFE: Only the initiation is checked; parts and completion are not
app.post('/upload/initiate', requireAuth, async (req, res) => {
  const upload = await s3.createMultipartUpload({
    Bucket: BUCKET, Key: req.body.key,
  }).promise();
  res.json({ uploadId: upload.UploadId, key: req.body.key });
});

// SAFE: Verify ownership on every step, store upload metadata server-side
app.post('/upload/initiate', requireAuth, async (req, res) => {
  const key = normalizePath(`uploads/${req.user.tenant_id}/`, req.body.filename);
  const upload = await s3.createMultipartUpload({
    Bucket: BUCKET, Key: key,
    ServerSideEncryption: 'aws:kms',
    ContentType: allowedMimeType(req.body.mimeType),
  }).promise();

  // Store the upload association server-side so parts can be verified
  await db.pendingUploads.create({
    upload_id: upload.UploadId,
    key,
    owner_id: req.user.id,
    tenant_id: req.user.tenant_id,
    expires_at: Date.now() + 24 * 3600_000,
  });
  res.json({ uploadId: upload.UploadId, key });
});

app.post('/upload/complete', requireAuth, async (req, res) => {
  const pending = await db.pendingUploads.findByUploadId(req.body.uploadId);
  if (!pending || pending.owner_id !== req.user.id) {
    return res.status(403).json({ error: 'forbidden' });
  }
  await s3.completeMultipartUpload({
    Bucket: BUCKET,
    Key: pending.key,
    UploadId: req.body.uploadId,
    MultipartUpload: { Parts: req.body.parts },
  }).promise();
  await db.pendingUploads.delete(pending.id);
  res.json({ key: pending.key });
});

Without the server-side pending upload record, an attacker can initiate an upload for their own key, get the UploadId, and then call CompleteMultipartUpload with a different key they found in a prior request (or guessed) — overwriting another tenant's object without triggering the initiation-level ownership check. Always bind the UploadId to the key and the owner server-side, and verify both on completion.

Bucket policy audit and infrastructure-as-code enforcement

Bucket policies drift over time: a developer with console access adds a permissive statement "temporarily" to debug an access issue, and it is never removed. Infrastructure-as-code (Terraform, Pulumi, CDK) prevents this by making the bucket policy a version-controlled artifact that is deployed by CI and never manually modified.

# Terraform — bucket policy managed as code, no console edits
resource "aws_s3_bucket_policy" "mcp_uploads" {
  bucket = aws_s3_bucket.mcp_uploads.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "DenyHTTP"
        Effect    = "Deny"
        Principal = "*"
        Action    = "s3:GetObject"
        Resource  = "${aws_s3_bucket.mcp_uploads.arn}/*"
        Condition = { Bool = { "aws:SecureTransport" = "false" } }
      },
      {
        Sid       = "DenyUnencryptedPuts"
        Effect    = "Deny"
        Principal = "*"
        Action    = "s3:PutObject"
        Resource  = "${aws_s3_bucket.mcp_uploads.arn}/*"
        Condition = {
          StringNotEquals = {
            "s3:x-amz-server-side-encryption" = "aws:kms"
          }
        }
      }
    ]
  })
}

Run terraform plan in CI on every PR that touches infrastructure files. Any drift detected between the planned state and the actual AWS state (caused by manual console changes) will surface as an unexpected diff. Use AWS Config rules — specifically s3-bucket-public-read-prohibited and s3-bucket-ssl-requests-only — as an independent continuous compliance check that fires alerts within minutes of any manual bucket policy change that introduces a violation.

SkillAudit findings for blob storage security

CRITICAL −24 Presigned URL generated without ownership verification — any authenticated caller can request a download URL for any object in the bucket.
CRITICAL −20 Public bucket ACL — objects readable without any authentication via direct S3 URLs, bypassing MCP server access controls entirely.
HIGH −15 No path normalization on user-supplied object key — path traversal with ../ segments reaches objects outside the caller's tenant prefix.
HIGH −12 Presigned URL TTL exceeds 1 hour — extended window for token theft, log exfiltration, or URL forwarding to unauthorized parties.
MEDIUM −8 No SSE-KMS enforcement via bucket policy — objects stored with S3-managed keys or unencrypted depending on SDK caller behaviour.

Run a SkillAudit scan to detect blob storage misconfigurations in your MCP server. See also MCP server access control.