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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Added

- `presign` and `download`: add `response-content-disposition`, `response-content-type`, and `response-cache-control` inputs for B2 response header overrides. ([#95](https://github.com/backblaze-labs/b2-action/issues/95))

## [1.1.0] - 2026-06-23

### Security
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,10 @@ the step; comparable SHA-1 mismatches also fail.
bucket: my-bucket
source: reports/2026-q1.pdf
presign-ttl: 7200
response-content-disposition: 'attachment; filename="q1-report.pdf"'
response-content-type: application/pdf

- run: curl -fSL "${{ steps.link.outputs.presigned-url }}" -o report.pdf
- run: curl --fail --show-error --location "${{ steps.link.outputs.presigned-url }}" -o report.pdf
```

### Server-side encryption
Expand Down Expand Up @@ -387,6 +389,9 @@ Set `bypass-governance: true` to shorten governance-mode retention or to remove
| `content-disposition` | no | | Content-Disposition response header to store with uploaded files. |
| `content-language` | no | | Content-Language response header to store with uploaded files. |
| `expires` | no | | Expires response header to store with uploaded files. |
| `response-content-disposition` | no | | Response Content-Disposition override for `presign` and `download`, such as `attachment; filename="report.pdf"`. |
| `response-content-type` | no | | Response Content-Type override for `presign` and `download`. |
| `response-cache-control` | no | | Response Cache-Control override for `presign` and `download`. |
| `preserve-mtime` | no | `false` | Store each uploaded file's local modification time as B2 `src_last_modified_millis`. |
| `dry-run` | no | `false` | Preview only (sync/delete/purge). |
| `allow-bucket-purge` | purge only | `false` | Permit `purge` to target the entire bucket when `source` is empty or `/`. |
Expand Down
3 changes: 3 additions & 0 deletions __tests__/_parsed-inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export function makeParsedInputs(
contentType: undefined,
fileInfo: {},
preserveMtime: false,
responseContentDisposition: undefined,
responseContentType: undefined,
responseCacheControl: undefined,
dryRun: false,
allowBucketPurge: false,
presignTtlSeconds: 3600,
Expand Down
64 changes: 63 additions & 1 deletion __tests__/commands/delete-copy-presign.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { copyCommand } from '../../src/commands/copy.ts'
import { deleteCommand } from '../../src/commands/delete.ts'
import { presignCommand } from '../../src/commands/presign.ts'
Expand Down Expand Up @@ -323,4 +323,66 @@ describe('presign command', () => {
expect(first?.url).toContain('expires=')
expect(first?.expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000))
})

it('adds response header overrides to presigned URLs and authorization', async () => {
const local = join(fx.workDir, 'private-report.bin')
await writeFile(local, 'share me as pdf')
await uploadCommand(fx.bucket, {
...baseInputs('upload'),
source: local,
destination: 'reports/private-report.bin',
})
const getAuth = vi.spyOn(fx.client.raw, 'getDownloadAuthorization')

const result = await presignCommand(fx.client, fx.bucket, {
...baseInputs('presign'),
source: 'reports/private-report.bin',
responseContentDisposition: 'attachment; filename="report.pdf"',
responseContentType: 'application/pdf',
responseCacheControl: 'private, max-age=60',
})

const request = getAuth.mock.calls[0]?.[2]
expect(request).toMatchObject({
b2ContentDisposition: 'attachment; filename="report.pdf"',
b2ContentType: 'application/pdf',
b2CacheControl: 'private, max-age=60',
})
const url = new URL(result.files[0]?.url ?? '')
expect(url.searchParams.get('b2ContentDisposition')).toBe('attachment; filename="report.pdf"')
expect(url.searchParams.get('b2ContentType')).toBe('application/pdf')
expect(url.searchParams.get('b2CacheControl')).toBe('private, max-age=60')
})

it.each([
[
'response-content-disposition',
{ responseContentDisposition: 'attachment; filename="safe.pdf"\r\nX-Evil: 1' },
],
['response-content-type', { responseContentType: 'text/plain\nX-Evil: 1' }],
['response-cache-control', { responseCacheControl: 'private\u0000, max-age=60' }],
] as const)('rejects malicious %s before presign authorization', async (inputName, override) => {
const getAuth = vi.fn()
const client = {
raw: { getDownloadAuthorization: getAuth },
accountInfo: {
getApiUrl: () => 'https://api.example.invalid',
getAuthToken: () => 'auth-token',
getDownloadUrl: () => 'https://download.example.invalid',
},
} as unknown as Parameters<typeof presignCommand>[0]
const bucket = {
id: 'bucket-id',
name: 'gh-action-misc',
} as unknown as Parameters<typeof presignCommand>[1]

await expect(
presignCommand(client, bucket, {
...baseInputs('presign'),
source: 'reports/private-report.bin',
...override,
}),
).rejects.toThrow(inputName)
expect(getAuth).not.toHaveBeenCalled()
})
})
49 changes: 48 additions & 1 deletion __tests__/commands/upload-download.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto'
import { mkdir, readFile, rename, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { Bucket, FileVersion, ProgressEvent } from '@backblaze-labs/b2-sdk'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { downloadCommand, replaceDownloadedFile } from '../../src/commands/download.ts'
import { uploadCommand } from '../../src/commands/upload.ts'
import type { ParsedInputs } from '../../src/inputs.ts'
Expand Down Expand Up @@ -249,6 +249,53 @@ describe('upload + download commands (B2Simulator)', () => {
expect(got.equals(payload)).toBe(true)
})

it('passes response header overrides to downloads', async () => {
await seedFile(fx, 'reports/private-report.bin', 'download me as pdf')
const download = vi.spyOn(fx.bucket, 'download')

await downloadCommand(fx.bucket, {
...baseInputs(),
action: 'download',
source: 'reports/private-report.bin',
destination: join(fx.workDir, 'report.pdf'),
responseContentDisposition: 'attachment; filename="report.pdf"',
responseContentType: 'application/pdf',
responseCacheControl: 'private, max-age=60',
})

expect(download.mock.calls[0]?.[1]).toMatchObject({
b2ContentDisposition: 'attachment; filename="report.pdf"',
b2ContentType: 'application/pdf',
b2CacheControl: 'private, max-age=60',
})
})

it.each([
[
'response-content-disposition',
{ responseContentDisposition: 'attachment; filename="safe.pdf"\r\nX-Evil: 1' },
],
['response-content-type', { responseContentType: 'text/plain\nX-Evil: 1' }],
['response-cache-control', { responseCacheControl: 'private\u0000, max-age=60' }],
] as const)('rejects malicious %s before download requests', async (inputName, override) => {
const download = vi.fn()
const bucket = {
name: 'gh-action-test',
download,
} as unknown as Parameters<typeof downloadCommand>[0]

await expect(
downloadCommand(bucket, {
...baseInputs(),
action: 'download',
source: 'reports/private-report.bin',
destination: join(fx.workDir, 'report.pdf'),
...override,
}),
).rejects.toThrow(inputName)
expect(download).not.toHaveBeenCalled()
})

it('downloads every file under a prefix', async () => {
for (const name of ['a.txt', 'b.txt', 'c.txt']) {
const local = join(fx.workDir, name)
Expand Down
15 changes: 15 additions & 0 deletions __tests__/inputs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,21 @@ describe('parseInputs', () => {
expect(r.dryRun).toBe(true)
})

it('parses download response header overrides', () => {
setInput('action', 'download')
setInput('application-key-id', 'k')
setInput('application-key', 's')
setInput('bucket', 'b')
setInput('response-content-disposition', 'attachment; filename="report.pdf"')
setInput('response-content-type', 'application/pdf')
setInput('response-cache-control', 'max-age=60')

const r = parseInputs()
expect(r.responseContentDisposition).toBe('attachment; filename="report.pdf"')
expect(r.responseContentType).toBe('application/pdf')
expect(r.responseCacheControl).toBe('max-age=60')
})

it('keeps an empty purge source only when whole-bucket purge is confirmed', () => {
setInput('action', 'purge')
setInput('application-key-id', 'k')
Expand Down
9 changes: 9 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ inputs:
expires:
description: 'Expires response header to store with uploaded files.'
required: false
response-content-disposition:
description: 'Response Content-Disposition override for presign/download, such as attachment; filename="report.pdf".'
required: false
response-content-type:
description: 'Response Content-Type override for presign/download.'
required: false
response-cache-control:
description: 'Response Cache-Control override for presign/download.'
required: false
preserve-mtime:
description: 'Store each uploaded file''s local modification time as B2 src_last_modified_millis. Default false.'
required: false
Expand Down
Loading
Loading