Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/http/plugins/signature-v4.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { getJwtSecret, getTenantConfig, s3CredentialsManager } from '@internal/d
import { ERRORS } from '@internal/errors'
import { RequestByteCounterStream } from '@internal/streams'
import { HashSpillWritable } from '@internal/streams/hash-stream'
import { ClientSignature, SignatureV4, SignatureV4Service } from '@storage/protocols/s3'
import {
assertPolicyConditionsSatisfied,
ClientSignature,
SignatureV4,
SignatureV4Service,
} from '@storage/protocols/s3'
import { ByteLimitTransformStream } from '@storage/protocols/s3/byte-limit-stream'
import {
ChunkSignatureV4Parser,
Expand Down Expand Up @@ -160,6 +165,12 @@ async function authorizeRequestSignV4(
)
}

if (clientSignature.policy) {
assertPolicyConditionsSatisfied(clientSignature.policy.value, clientSignature.policy.fields, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if we should move this check inside the signature-v4 parseMultipartSignature so that we validate it before returning it

bucket: (request.params as Record<string, string | undefined>)?.Bucket,
})
}

const wasBodyHashed = allowBodyHash && byteHasherStream && byteHasherStream.writableEnded

const returnStream = wasBodyHashed
Expand Down Expand Up @@ -203,7 +214,7 @@ async function authorizeRequestSignV4(
return returnStream
}

async function extractSignature(req: AWSRequest) {
async function extractSignature(req: AWSRequest): Promise<ClientSignature> {
if (typeof req.headers.authorization === 'string') {
return SignatureV4.parseAuthorizationHeader(req.headers)
}
Expand Down
1 change: 1 addition & 0 deletions src/storage/protocols/s3/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './policy'
export * from './signature-v4'
170 changes: 170 additions & 0 deletions src/storage/protocols/s3/policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { ERRORS } from '@internal/errors'

/**
* A decoded AWS POST policy document: an expiration plus a list of conditions
* that the submitted upload form must satisfy.
*/
export interface Policy {
expiration: string
conditions: PolicyCondition[]
}

/**
* An AWS POST policy condition is either:
* - an exact-match object with a single key: `{ "bucket": "my-bucket" }`
* - a tuple: `["eq", "$key", "value"]`, `["starts-with", "$key", "prefix"]`,
* or `["content-length-range", min, max]`
*/
export type PolicyCondition = Record<string, string> | (string | number)[]

/**
* Submitted form fields that do NOT need to be covered by a policy condition,
* keyed by their lowercased name. This mirrors AWS POST semantics: every other
* field (including `key`, `bucket`, and every `x-amz-meta-*`) must be constrained
* by a condition, otherwise a holder of a signed policy could attach arbitrary
* uncovered fields. `file` is stripped before signature parsing; fields prefixed
* with `x-ignore-` are AWS's documented escape hatch and are also exempt.
*/
const EXEMPT_POLICY_FIELDS = new Set([
'policy',
'x-amz-signature',
'x-amz-algorithm',
'x-amz-credential',
'x-amz-date',
'x-amz-security-token',
])

/**
* Validates a POST policy `expiration`, an absolute ISO8601 timestamp (e.g.
* `2025-01-01T00:00:00Z`). Compares it directly to the current time and never
* relies on any client-supplied date. The expiration is required: a policy
* without one is rejected as invalid.
*/
export function assertPolicyNotExpired(expiration: string | undefined): void {
if (!expiration) {
throw ERRORS.InvalidSignature('Missing policy expiration')
}

const expirationDate = new Date(expiration)
if (isNaN(expirationDate.getTime())) {
throw ERRORS.InvalidSignature('Invalid policy expiration')
}

if (expirationDate < new Date()) {
throw ERRORS.ExpiredSignature()
}
}

/**
* Evaluates the signed POST policy conditions against the values the client
* actually submitted, in a single pass. Throws if any condition is not
* satisfied, or if any submitted field is not covered by a condition (see
* {@link EXEMPT_POLICY_FIELDS}).
*
* The `bucket` value is taken from the request target (the URL), since AWS POST
* uploads carry the bucket in the path rather than as a form field.
*
* @param policy the decoded policy document
* @param submittedFields the submitted form fields, keyed by lowercased name
* @param opts.bucket the bucket resolved from the request path
*/
export function assertPolicyConditionsSatisfied(
policy: Policy,
submittedFields: Record<string, string>,
opts: { bucket?: string }
): void {
const conditions = policy?.conditions
if (!Array.isArray(conditions)) {
throw ERRORS.InvalidSignature('Invalid policy conditions')
}

const fields: Record<string, string> = { ...submittedFields }
if (opts.bucket !== undefined) {
fields['bucket'] = opts.bucket
}

const coveredFields = new Set<string>()
for (const condition of conditions) {
const field = evaluatePolicyCondition(condition, fields)
if (field) {
coveredFields.add(field)
}
}

// Every submitted field must be constrained by a condition
for (const field of Object.keys(fields)) {
if (EXEMPT_POLICY_FIELDS.has(field) || field.startsWith('x-ignore-')) {
continue
}
if (!coveredFields.has(field)) {
throw ERRORS.AccessDenied(
`Policy condition failed: field "${field}" is not covered by the policy`
)
}
}
}

function evaluatePolicyCondition(
condition: PolicyCondition,
fields: Record<string, string>
): string | undefined {
// Exact-match object form: { "field": "value" } with exactly one key.
if (condition && typeof condition === 'object' && !Array.isArray(condition)) {
const keys = Object.keys(condition)
if (keys.length !== 1) {
throw ERRORS.InvalidSignature('Invalid policy condition')
}
const field = keys[0].toLowerCase()
assertFieldEquals(field, condition[keys[0]], fields)
return field
}

// Tuple form: ["eq" | "starts-with", "$field", value] or
// ["content-length-range", min, max].
if (Array.isArray(condition)) {
const [operator, target, value] = condition

// TODO: content-length-range (min/max file size) is recognized but NOT
// enforce yet. enforcement is tracked as a follow-up).
if (operator === 'content-length-range') {
return undefined
}

if ((operator === 'eq' || operator === 'starts-with') && typeof target === 'string') {
if (!target.startsWith('$')) {
throw ERRORS.InvalidSignature('Invalid policy condition')
}
const field = target.slice(1).toLowerCase()
if (operator === 'eq') {
assertFieldEquals(field, value, fields)
} else {
assertFieldStartsWith(field, value, fields)
}
return field
}
}

throw ERRORS.InvalidSignature('Unsupported policy condition')
}

function assertFieldEquals(
field: string,
expected: string | number,
fields: Record<string, string>
): void {
const actual = fields[field]
if (actual === undefined || actual !== String(expected)) {
throw ERRORS.AccessDenied(`Policy condition failed: "${field}" does not match`)
}
}

function assertFieldStartsWith(
field: string,
prefix: string | number,
fields: Record<string, string>
): void {
const actual = fields[field]
if (actual === undefined || !actual.startsWith(String(prefix ?? ''))) {
throw ERRORS.AccessDenied(`Policy condition failed: "${field}" does not start with "${prefix}"`)
}
}
42 changes: 19 additions & 23 deletions src/storage/protocols/s3/signature-v4.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ERRORS } from '@internal/errors'
import crypto from 'crypto'
import { Readable } from 'stream'
import { pipeline } from 'stream/promises'
import { assertPolicyNotExpired, Policy } from './policy'

export enum SignatureV4Service {
S3 = 's3',
Expand All @@ -29,6 +30,7 @@ export interface ClientSignature {
policy?: {
raw: string
value: Policy
fields: Record<string, string>
}
}

Expand All @@ -52,26 +54,6 @@ interface Credentials {
type SignatureHeaders = Record<string, string | string[] | undefined>
type SignatureQuery = Record<string, unknown>

export interface Policy {
expiration: string
conditions: PolicyConditions
}

interface PolicyConditions {
policies: PolicyEntry[]
contentLengthRange: {
min: number
max: number
valid: boolean
}
}

interface PolicyEntry {
operator: string
key: string
value: string
}

/**
* Lists the headers that should never be included in the
* request signature signature process.
Expand Down Expand Up @@ -230,9 +212,22 @@ export class SignatureV4 {

const xPolicy: Policy = JSON.parse(Buffer.from(policy, 'base64').toString('utf-8'))

if (xPolicy.expiration) {
this.checkExpiration(longDate, xPolicy.expiration)
}
// A POST policy must declare an expiration; AWS treats a missing one as
// invalid. Without this check a signed policy with no expiration would never
// expire and could be replayed forever.
assertPolicyNotExpired(xPolicy.expiration)

const fields: Record<string, string> = Object.create(null)
form.forEach((value, key) => {
if (typeof value !== 'string') {
return
}
const normalizedKey = key.toLowerCase()
if (Object.prototype.hasOwnProperty.call(fields, normalizedKey)) {
throw ERRORS.InvalidSignature('Duplicate form field in POST policy request')
}
fields[normalizedKey] = value
})

const credentialsPart = credentialPart.split('/')
if (credentialsPart.length !== 5) {
Expand All @@ -250,6 +245,7 @@ export class SignatureV4 {
policy: {
raw: policy,
value: xPolicy,
fields,
},
}
}
Expand Down
Loading