✓ Human-authored analysis; AI used for formatting and proofreading.
In 2015 a researcher pointed at HackerOne's own attachment bucket and noted that the objects were retrievable over plain HTTP. The data was encrypted at rest. The bucket was private. The objects required authentication. But the HTTP transport itself was unencrypted, and the bucket policy did not deny non-TLS requests. The report became HackerOne #43280.
This is the configuration gap that survives every "encrypt your data" presentation. Encryption-at-rest is the default. Encryption-in-transit is not. Closing the gap is one bucket policy statement, eight lines of JSON. Not closing it is a HIPAA finding (164.312(e)(2)(ii)), a PCI-DSS finding (4.2.1), a SOC 2 finding (CC6.1), and a CIS AWS finding (2.1.1) — simultaneously, on the same bucket, every audit cycle.
The configuration that fails
{
"storage": {
"kind": "bucket",
"id": "aws:s3:::hackerone-attachments",
"access": {
"public_read": false,
"public_list": false
},
"encryption": {
"in_transit_enforced": false
},
"controls": {
"public_access_fully_blocked": true,
"public_access_block": {
"block_public_acls": true,
"ignore_public_acls": true,
"block_public_policy": true,
"restrict_public_buckets": true
}
}
}
}
Every line in this block reads like a well-secured bucket. Public read off. Public list off. Public Access Block fully enabled (all four flags). Anonymous access denied at every layer.
The vulnerability is one field: in_transit_enforced: false. The bucket allows HTTPS or HTTP. Any client that calls a presigned URL, a redirect, or a misconfigured CDN over HTTP gets the object delivered in plaintext over the wire.
In transit
AWS S3 supports HTTPS on every endpoint. The question is not "can the bucket be reached over HTTPS?" — it can. The question is "does the bucket refuse HTTP?" and the default answer is no.
The fix is a single bucket policy statement:
{
"Statement": [{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::hackerone-attachments",
"arn:aws:s3:::hackerone-attachments/*"
],
"Condition": {
"Bool": { "aws:SecureTransport": "false" }
}
}]
}
aws:SecureTransport is an IAM condition key. When false, the request came in over HTTP. The deny statement above blocks every action on the bucket from every principal when the request is not TLS. The deny is unconditional, applies to authenticated and anonymous requests alike, and is the canonical fix referenced by every compliance benchmark.
Without this statement, the bucket policy does not say "HTTPS required." It says "any transport works." The default-deny logic of IAM does not save you here. s3:GetObject is explicitly allowed by the bucket policy or by the caller's IAM, and the policy evaluator does not care which TCP socket it arrived on.
Why this hides
The check is fast in audit. The check is invisible in operation. Three reasons:
S3 console UX never surfaces it. The console encourages you to set up encryption at rest (KMS or SSE-S3), configure replication, configure lifecycle. Transport encryption is a policy concern, and you only see it if you go read the policy. The console's "Permissions" tab does not have an "HTTPS required" checkbox the way it has a "Block public access" checkbox.
Working HTTPS hides broken HTTP. Every SDK uses HTTPS by default. Every console operation uses HTTPS. Every CloudFront-fronted object uses HTTPS. The HTTP path is reachable but rarely traversed by your own code. Until an old SDK, a legacy integration, a custom uploader, or a presigned URL leak takes the HTTP path, nothing visibly breaks.
Scanners report compliance, not transport. Most scanners flag "encryption at rest enabled?" with a checkmark and move on. The transport-encryption check is a separate rule, often grouped with TLS-version checks on ALB and CloudFront, and is missing from a surprising number of CSPM defaults. CIS AWS v3.0 control 2.1.1 explicitly calls it out and the corresponding policy-as-code rules are present in OPA-libraries but often disabled because they generate noise.
The system invariant
The invariant for the data layer:
No S3 bucket may permit object access over a non-TLS transport.
Restated as a fact predicate: for every bucket, storage.encryption.in_transit_enforced must be true. The boolean is a projection of "does this bucket have a policy statement denying aws:SecureTransport: false?" produced once by the extractor, then evaluated by every consumer (audit, gate, dashboard, compliance report) against the same projected value.
What Stave detects
id: CTL.S3.ENCRYPT.002
name: Transport Encryption Required
description: >
S3 buckets must enforce HTTPS via a deny policy on aws:SecureTransport=false.
Without this, data transfers occur in plaintext.
severity: high
compliance:
hipaa: "164.312(e)(2)(ii)"
cis_aws_v3.0: "2.1.1"
pci_dss_v4.0: "4.2.1"
soc2: "CC6.1"
nist_800_53_r5: "SC-8"
unsafe_predicate:
all:
- field: properties.storage.kind
op: eq
value: bucket
- field: properties.storage.encryption.in_transit_enforced
op: eq
value: false
Two-line predicate. The first line scopes to buckets (the same property block can describe replication targets or website endpoints; this control only applies to the bucket asset). The second line is the actual check: in-transit enforcement must be true.
The compliance block carries six framework citations. Each is a separately motivated reason this control exists. None of them changes the predicate. The rule is the same regardless of which audit the customer is preparing for.
Run the fixture
cd stave && make build
# The fixture ships two snapshots (T1 unsafe, T2 fixed). Evaluate T1 alone to
# reproduce the original finding.
tmp=$(mktemp -d)
cp testdata/e2e/e2e-h1-hackerone-43280/observations/2015-01-11T000000Z.json "$tmp/"
./stave apply \
--controls testdata/e2e/e2e-h1-hackerone-43280/controls \
--observations "$tmp" \
--max-unsafe 168h \
--eval-time 2015-01-18T00:00:00Z \
--allow-unknown-input \
--format json | jq '.findings[0]'
{
"control_id": "CTL.S3.ENCRYPT.002",
"control_severity": "high",
"asset_id": "aws:s3:::hackerone-attachments",
"evidence": {
"misconfigurations": [
{ "property": "storage.encryption.in_transit_enforced",
"actual_value": false,
"operator": "eq",
"unsafe_value": false }
]
},
"remediation": {
"description": "Bucket does not enforce HTTPS for data transfers. Requests over plain HTTP transmit data in cleartext.",
"action": "Add a bucket policy statement that denies all actions when aws:SecureTransport is false. This forces all API calls to use HTTPS."
}
}
One field, property path and shell-pasteable fix. The compliance citations live on the control YAML, they show up in the report renderer when the operator asks for "show me findings tagged HIPAA" or "show me findings tagged CIS AWS v3.0."
The remediation
aws s3api put-bucket-policy \
--bucket hackerone-attachments \
--policy '{
"Version": "2012-10-17",
"Statement": [{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::hackerone-attachments",
"arn:aws:s3:::hackerone-attachments/*"
],
"Condition": { "Bool": { "aws:SecureTransport": "false" } }
}]
}'
If the bucket already has a policy with other statements (e.g., a CloudFront OAI grant), append the DenyInsecureTransport statement to the existing policy rather than overwriting. The deny is evaluated before any allow in IAM policy logic, so it composes with any existing allow statements you have.
Why it generalises
This is the cheapest possible class of HIPAA-grade finding to close. One bucket policy statement, eight lines of JSON, zero performance cost, no application changes required. The reason it persists in the wild is the same reason most cloud security findings persist: the configuration was set up before the compliance bar existed, never re-audited against the current bar, and the tool the team uses to audit does not check this specific predicate by default.
The right answer is a presence check at the project layer: every bucket your snapshot extractor finds must have in_transit_enforced: true, evaluated continuously, in CI, with the failing PR blocked from merging until the policy statement is added. Stave's CTL.S3.ENCRYPT.002 is that presence check, the fixture above is the e2e regression test for it, and the cost of running it on every commit is roughly the cost of one s3:GetBucketPolicy call per bucket well within the API budget of every audit pipeline.
HackerOne fixed their own bucket policy in 2015. The class of finding has not disappeared since.
Top comments (0)