DEV Community

Bala Paranj
Bala Paranj

Posted on

I Ran a Security Scanner Against Mastodon, Discourse, and Chatwoot's AWS Defaults. Here's What I Found.

✓ Human-authored analysis; AI used for formatting and proofreading.

You deploy a Rails app to AWS. You follow the README. File uploads work. You move on.

But what just happened to the S3 bucket your app is writing to? Is it encrypted? Is it public? Could someone use it to ransom your data?

I used Stave, an open-source configuration safety tool, to answer those questions for three of the most popular open-source Rails applications: Mastodon (47K stars), Discourse (43K stars), and Chatwoot (22K stars).

The results: 47 security findings across three projects. Two of the three default to publicly readable S3 buckets. None configure encryption, access logging, or Public Access Block.

This isn't a vulnerability disclosure. These projects work exactly as documented. The problem is the documentation.

The Methodology

  1. Clone each project's source code
  2. Extract AWS configuration from initializers, config/storage.yml, environment templates, and site settings
  3. Generate JSON snapshots representing "what a deployer gets if they follow the docs without additional hardening"
  4. Run Stave's control catalog against those snapshots

No AWS credentials needed. No running infrastructure. Stave evaluates configuration snapshots offline. It checks what the bucket would look like based on the application defaults.

Finding #1: Mastodon Defaults to public-read

Open config/initializers/paperclip.rb in Mastodon's repo. Line 57:

s3_permissions: ENV.fetch('S3_PERMISSION') { 'public-read' },
Enter fullscreen mode Exit fullscreen mode

If you don't set the S3_PERMISSION environment variable, every file your Mastodon instance uploads to S3 gets a public-read ACL. Profile pictures, media attachments, header images are all readable by anyone on the internet.

This is intentional for federation. Mastodon needs remote servers to fetch media. But the security implications go beyond serving images:

  • No safety net. Without S3 Public Access Block enabled, there is no account-level guard preventing this bucket or any bucket in your AWS account from being public.
  • Scanners are watching. Automated tools continuously enumerate S3 bucket names. Once your bucket name leaks (it's in every image URL your instance serves), every object key someone can guess is downloadable.
  • ACLs override policies. Even if you add a restrictive bucket policy later, the object-level public-read ACL still grants access. The ACL and the policy are evaluated independently either one granting access is sufficient.

Stave flagged this as CTL.S3.PUBLIC.001 critical severity, exposure score 100/100.

The fix for Mastodon deployers: Set S3_PERMISSION='' (empty string) in your environment. Line 75 of the same file disables ACLs entirely when this is set:

Paperclip::Attachment.default_options[:s3_permissions] = ->(*) {} if ENV['S3_PERMISSION'] == ''
Enter fullscreen mode Exit fullscreen mode

Finding #2: Discourse Does the Same Thing, But Through Admin Settings

Discourse doesn't use environment variables for S3 ACLs. It uses SiteSetting:

# config/site_settings.yml:2629
s3_use_acls:
  default: true
Enter fullscreen mode Exit fullscreen mode

Combined with:

secure_uploads:
  default: false
Enter fullscreen mode Exit fullscreen mode

When s3_use_acls is true and secure_uploads is false, non-secure uploads get a public-read ACL. The logic lives in lib/file_store/s3_store.rb:

# lib/file_store/s3_store.rb:390
def s3_bucket_folder_path
  # When secure_uploads is disabled, uploads are public
end
Enter fullscreen mode Exit fullscreen mode

Same critical finding. Same exposure. Different mechanism.

The fix for Discourse deployers: Enable secure_uploads in admin settings, or disable s3_use_acls.

Finding #3: Chatwoot Gets One Thing Right

Chatwoot uses Active Storage with S3. Here's their config/storage.yml:

amazon:
  service: S3
  access_key_id: <%= ENV.fetch('AWS_ACCESS_KEY_ID', '') %>
  secret_access_key: <%= ENV.fetch('AWS_SECRET_ACCESS_KEY', '') %>
  region: <%= ENV.fetch('AWS_REGION', '') %>
  bucket: <%= ENV.fetch('S3_BUCKET_NAME', '') %>
Enter fullscreen mode Exit fullscreen mode

No ACL configuration. Active Storage creates private objects by default. Chatwoot is the only project of the three that does NOT default to public-read.

Result: 15 findings instead of 16. The public access finding doesn't fire. Everything else does.

The 15 Findings Every Rails-on-S3 Deployer Shares

All three projects share these findings. They are infrastructure settings that Rails applications never touch and that setup documentation never mentions:

Critical

Finding What It Means in Plain English
SSE-C not disabled Anyone with s3:PutObject permission can encrypt your files with their own key. You can't decrypt them. AWS can't help. This is the S3 ransomware vector and it requires zero KMS permissions.

High

Finding What It Means in Plain English
No account-level Public Access Block Any bucket in your AWS account can be made public by a single misconfiguration. PAB is the kill switch that prevents it.
No bucket-level Public Access Block Same as above, but per-bucket. Without it, a bad bucket policy or ACL change exposes your data with no warning.
No encryption at rest Your files sit unencrypted on AWS disks. If AWS has a physical security incident, your data is readable. Every compliance framework requires this.
No HTTPS enforcement Without a bucket policy denying aws:SecureTransport=false, someone could access your bucket over plain HTTP. Your Rails app uses HTTPS, but the S3 API doesn't enforce it by default.
No VPC endpoint restriction Your bucket is reachable from any network path on the internet. No IP or VPC condition limits who can even try to connect.
No ownership controls ACLs are still active on the bucket. Any uploader can set object-level ACLs that override your bucket policy.
BlockPublicAcls not enabled Someone can add a public-read ACL to an object. PAB would reject this at the API level.
BlockPublicPolicy not enabled Someone can add a bucket policy granting Principal: "*" access. PAB would reject this at the API level.
IgnorePublicAcls not enabled Existing public ACLs are honored. PAB would ignore them.
RestrictPublicBuckets not enabled Cross-account access via public policies is unrestricted. PAB would block it.

Medium

Finding What It Means in Plain English
No access logging You have zero visibility into who accessed your bucket, when, and what they downloaded. If you get breached, you won't know what was taken.
No versioning If someone deletes or overwrites a file, it's gone. No recovery. Combined with the ransomware vector above, this means a complete data loss scenario.
No multipart upload cleanup Incomplete multipart uploads accumulate forever, costing you money. A lifecycle policy cleans these up automatically.

Low

Finding What It Means in Plain English
No governance controls No Object Lock, no legal hold, no retention policies. Your files can be deleted by any principal with the right permissions.

Why This Happens: The Rails-AWS Gap

Rails has excellent abstractions for storage. Active Storage and Paperclip make file uploads trivial. But they abstract at the application level what goes into the bucket, not how the bucket is configured.

The bucket is infrastructure. Infrastructure configuration lives in a different world:

Your Rails app                  Your AWS account
┌──────────────┐                   ┌──────────────────────┐
│  Paperclip   │── s3:PutObject ──▶│  S3 Bucket           │
│  or Active   │                   │  - No encryption     │
│  Storage     │                   │  - No PAB            │
│              │                   │  - No logging        │
│  "It works!" │                   │  - No versioning     │
└──────────────┘                   │  - SSE-C enabled     │
                                   │  - Public ACLs ok    │
                                   └──────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Your app works. Your bucket is a liability.

This isn't a Rails problem specifically. It's a deployment documentation problem. The gap exists because:

  1. Rails gems configure the client, not the bucket. aws-sdk-s3 sets up the connection. It doesn't run put-bucket-encryption or put-public-access-block.

  2. Setup docs stop at "uploads work." Mastodon's docs explain how to configure S3 credentials. They don't mention encryption, PAB, or access logging.

  3. Terraform and CloudFormation exist, but aren't part of the Rails story. Infrastructure-as-code tools handle bucket hardening well. But a developer following a Rails project's README will create a bucket through the AWS console and move on.

  4. Security is someone else's job. Application developers think the cloud team handles bucket security. Solo deployers don't have a cloud team.

How Stave Works

Stave doesn't scan your AWS account or need credentials. It evaluates JSON snapshots. A structured representations of your infrastructure configuration against a catalog of controls written as CEL (Common Expression Language) predicates.

For example, the public access control checks:

storage.access.public_read eq true
Enter fullscreen mode Exit fullscreen mode

If that property is true in your observation snapshot, the control fires. Each control maps to compliance frameworks (CIS, NIST, PCI DSS, HIPAA, SOC 2) and includes a severity, remediation steps, and an exposure score.

For this analysis, I extracted configuration defaults from source code and translated them into Stave observation snapshots. The approach is deterministic: given the same source code defaults, Stave will always produce the same findings.

What You Should Do

If you're deploying any Rails application to AWS with S3:

Right now (5 minutes):

# Enable account-level Public Access Block
aws s3control put-public-access-block \
  --account-id YOUR_ACCOUNT_ID \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
Enter fullscreen mode Exit fullscreen mode

This single command prevents any bucket in your account from being accidentally made public. It's the highest-impact change you can make.

This week:

# Enable default encryption on your bucket
aws s3api put-bucket-encryption \
  --bucket YOUR_BUCKET \
  --server-side-encryption-configuration \
  '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'

# Enforce HTTPS
aws s3api put-bucket-policy \
  --bucket YOUR_BUCKET \
  --policy '{
    "Version":"2012-10-17",
    "Statement":[{
      "Sid":"EnforceHTTPS",
      "Effect":"Deny",
      "Principal":"*",
      "Action":"s3:*",
      "Resource":["arn:aws:s3:::YOUR_BUCKET","arn:aws:s3:::YOUR_BUCKET/*"],
      "Condition":{"Bool":{"aws:SecureTransport":"false"}}
    }]
  }'

# Enable access logging
aws s3api put-bucket-logging \
  --bucket YOUR_BUCKET \
  --bucket-logging-status \
  '{"LoggingEnabled":{"TargetBucket":"YOUR_LOG_BUCKET","TargetPrefix":"s3-access-logs/"}}'

# Enable versioning
aws s3api put-bucket-versioning \
  --bucket YOUR_BUCKET \
  --versioning-configuration Status=Enabled
Enter fullscreen mode Exit fullscreen mode

If you're running Mastodon: Set S3_PERMISSION='' in your environment. This is the most important single change.

If you're running Discourse: Enable secure_uploads in your admin panel.

The Scorecard

Project Findings Worst Finding One-Line Fix
Mastodon 16 public-read default S3_PERMISSION=''
Discourse 16 public-read via SiteSetting Enable secure_uploads
Chatwoot 15 SSE-C ransomware vector Account-level PAB

Chatwoot's Active Storage default (private objects) is the safer pattern. If you're building a new Rails app, prefer Active Storage's defaults over configuring ACLs.

Methodology Notes

  • Analysis based on source code defaults only. Actual deployments vary based on operator configuration.
  • Observation snapshots represent "follow the docs, change nothing else" deployments.
  • Stave's control catalog covers S3 only for this analysis. Discourse's SNS, MediaConvert, and Bedrock integrations were not evaluated.
  • The public-read default in Mastodon is documented behavior. Discourse's ACL behavior is configurable. Neither is a vulnerability. Both are deployment defaults that produce a weak security posture.

Stave is an open-source configuration safety tool. It evaluates infrastructure configuration snapshots against security controls without credentials or API access. Install it with go install github.com/sufield/stave@latest.

Top comments (0)