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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,32 @@ volumes:
cache-data:
```

## Signed cache URLs (optional)

The server-proxied upload and download URLs (`/devstoreaccount1/upload/{id}` and
`/download/{id}`) are unauthenticated by default. You can enable an expiring HMAC signature
on them:

| Env var | Description |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `URL_SIGNING_ENABLED` | `boolean`, default `false`. When `true`, upload/download URLs are signed on generation and strictly verified on the handlers (no unsigned fallback). |
| `URL_SIGNING_SECRET` | The active signing secret, **β‰₯ 16 chars**. Signs every issued URL and is the first candidate on verification. Required when signing is enabled β€” boot fails otherwise. |
| `URL_SIGNING_SECRET_SECONDARY` | Optional verify-only rotation secret, **β‰₯ 16 chars when set**. Never signs; accepted on verification so URLs minted with the previous secret keep working. |

Notes:

- **Stable shared value:** the secrets must be identical across all cluster workers/pods, or
a signature minted by one worker will fail verification on another.
- **Rotation:** move the current `URL_SIGNING_SECRET` into `URL_SIGNING_SECRET_SECONDARY`,
set the new secret as `URL_SIGNING_SECRET`, then drop `URL_SIGNING_SECRET_SECONDARY` after
the 1h signature TTL has elapsed β€” no disruption.
- **Fixed 1h TTL:** signatures expire after 1 hour and cannot be refreshed mid-upload, so a
single upload running longer than 1h will fail.
- **Enabling is a hard cutover:** flipping `URL_SIGNING_ENABLED` to `true` immediately makes
every already-issued unsigned URL return `401`. Downloads mostly recover (buildx
re-requests a fresh URL), but in-flight cache saves fail and re-run on the next job β€”
prefer enabling during low activity.

## Documentation

πŸ‘‰ <https://gha-cache-server.falcondev.io/getting-started> πŸ‘ˆ
28 changes: 16 additions & 12 deletions lib/env.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import type { envDbDriverSchema, envStorageDriverSchema } from './schemas'
import arkenv from 'arkenv'
import { envSchema } from './schemas'
import { envSchema, envSchemaValidated } from './schemas'

export const env = arkenv(envSchema, {
env: Object.assign(
{
STORAGE_DRIVER: 'filesystem',
STORAGE_FILESYSTEM_PATH: '.data/storage/filesystem',
DB_DRIVER: 'sqlite',
DB_SQLITE_PATH: '.data/sqlite.db',
} satisfies typeof envStorageDriverSchema.infer & typeof envDbDriverSchema.infer,
process.env,
),
})
// arkenv drops arktype's object morphs on a narrowed root (see `envSchemaValidated`),
// so re-apply them by running its output through `envSchema.assert`.
export const env = envSchema.assert(
arkenv(envSchemaValidated, {
env: Object.assign(
{
STORAGE_DRIVER: 'filesystem',
STORAGE_FILESYSTEM_PATH: '.data/storage/filesystem',
DB_DRIVER: 'sqlite',
DB_SQLITE_PATH: '.data/sqlite.db',
} satisfies typeof envStorageDriverSchema.infer & typeof envDbDriverSchema.infer,
process.env,
),
}),
)
32 changes: 32 additions & 0 deletions lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,39 @@ export const envBaseSchema = type({
'BENCHMARK': 'boolean = false',
'SKIP_TOKEN_VALIDATION': 'boolean = false',
'MANAGEMENT_API_KEY?': 'string',
'URL_SIGNING_ENABLED': 'boolean = false',
'URL_SIGNING_SECRET?': type('string').pipe((s) => s.trim()),
'URL_SIGNING_SECRET_SECONDARY?': type('string').pipe((s) => s.trim()),
})

export const envSchema = envBaseSchema.and(envStorageDriverSchema).and(envDbDriverSchema)
export type Env = typeof envSchema.infer

const URL_SIGNING_SECRET_MIN_LENGTH = 16

/**
* Cross-field validation for the signing secrets, used by `lib/env.ts`. Separate
* from `envSchema` because arkenv's `.get()` throws on a narrowed schema, and
* `tests/setup.ts` calls `envSchema.get(...)`.
*
* Validation only β€” arkenv drops object morphs on a narrowed root, so `lib/env.ts`
* re-applies them via `envSchema.assert(...)` and the predicate must `.trim()` the
* secrets itself (it sees the raw, un-morphed value).
*/
export const envSchemaValidated = envSchema.narrow((data, ctx) => {
if (!data.URL_SIGNING_ENABLED) return true

const secret = data.URL_SIGNING_SECRET?.trim()
if (!secret || secret.length < URL_SIGNING_SECRET_MIN_LENGTH)
return ctx.reject(
`URL_SIGNING_ENABLED requires URL_SIGNING_SECRET (>= ${URL_SIGNING_SECRET_MIN_LENGTH} chars)`,
)

const secondary = data.URL_SIGNING_SECRET_SECONDARY?.trim()
if (secondary && secondary.length < URL_SIGNING_SECRET_MIN_LENGTH)
return ctx.reject(
`URL_SIGNING_SECRET_SECONDARY must be >= ${URL_SIGNING_SECRET_MIN_LENGTH} chars when set`,
)

return true
})
13 changes: 10 additions & 3 deletions lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
renewReaderLease,
} from './storage-leases'
import { deleteStorageLocationIfUnread, noActiveReaderLease } from './storage-lifecycle'
import { signQuery, urlSigningConfigFromEnv } from './url-signing'

// Bounds the self-heal retry when matching keeps surfacing Dangling Cache
// Entries for the same prefix β€” caps a pathological scan (ADR-0005).
Expand Down Expand Up @@ -690,11 +691,17 @@ export class Storage {
continue
}

const defaultUrl = `${env.API_BASE_URL}/download/${cacheEntry.match.id}`
// The signed proxied URL β€” computed lazily so the HMAC is only minted on
// the code paths that actually emit it.
const proxiedDownloadUrl = () =>
`${env.API_BASE_URL}/download/${cacheEntry.match.id}${signQuery(
`/download/${cacheEntry.match.id}`,
urlSigningConfigFromEnv(),
)}`

if (!env.ENABLE_DIRECT_DOWNLOADS || !this.adapter.createDownloadUrl || !location.mergedAt)
return {
downloadUrl: defaultUrl,
downloadUrl: proxiedDownloadUrl(),
cacheEntry: cacheEntry.match,
}

Expand Down Expand Up @@ -725,7 +732,7 @@ export class Storage {
`${leased.folderName}/merged`,
directDownloadExpiresAt,
)
: defaultUrl
: proxiedDownloadUrl()

return {
downloadUrl,
Expand Down
111 changes: 111 additions & 0 deletions lib/url-signing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { H3Event } from 'h3'

import { createHmac, timingSafeEqual } from 'node:crypto'

import { createError, getQuery } from 'h3'
import { env } from '~/lib/env'

/**
* Fixed lifetime of a signed URL. Not configurable: 1h keeps a leaked URL
* short-lived while covering any realistic upload. The upload signature is minted
* once at `CreateCacheEntry` for the whole multi-chunk upload. Clients do not
* re-fetch the upload URL on 401 so an upload exceeding 1h fails hard.
*/
export const URL_SIGNING_TTL_MS = 60 * 60 * 1000 // 1 hour

/**
* Redact the `sig` bearer credential from a path/URL before it is logged. `sig`
* is replayable for the whole TTL, so a leaked log line must not carry it; the
* rest of the path and query (including `exp`) is left intact. Every code path
* that logs a request path MUST route through this.
*/
export function redactSignedPath(path: string): string {
return path.replace(/([?&]sig=)[^&]*/i, '$1[redacted]')
}

export interface UrlSigningConfig {
enabled: boolean
/** The active signing secret. Also the first candidate on verify. */
secret: string
/** Optional verify-only rotation secret (the previous `secret`). */
secondary?: string
}

/**
* The only reader of the `env` singleton. The core takes config explicitly so it
* can be unit-tested across enabled/disabled/rotation cases without env juggling.
*/
export function urlSigningConfigFromEnv(): UrlSigningConfig {
return {
enabled: env.URL_SIGNING_ENABLED,
secret: env.URL_SIGNING_SECRET ?? '',
secondary: env.URL_SIGNING_SECRET_SECONDARY || undefined,
}
}

/** Ordered verify candidates: the active secret first, then the rotation secret. */
function verifySecrets(config: UrlSigningConfig): string[] {
return [config.secret, config.secondary].filter((s): s is string => Boolean(s))
}

function computeSignature(canonicalPath: string, exp: number, secret: string): string {
return createHmac('sha256', secret).update(`${canonicalPath}\n${exp}`).digest('base64url')
}

/**
* Build the `?exp=<unixMs>&sig=<base64url>` suffix binding `canonicalPath + exp`.
* Returns `''` when disabled. Always signs with the active `config.secret`.
*
* `canonicalPath` MUST be an invariant resource id (e.g. `/upload/{id}`) built
* from the raw route param β€” never `event.path` β€” so the route alias and any
* reverse-proxy/base-path prefix all verify against the same signed material.
*/
export function signQuery(canonicalPath: string, config: UrlSigningConfig): string {
if (!config.enabled) return ''

const exp = Date.now() + URL_SIGNING_TTL_MS
const sig = computeSignature(canonicalPath, exp, config.secret)
return `?exp=${exp}&sig=${sig}`
}

/**
* Strictly verify the `exp`/`sig` query params against `canonicalPath`. No-op
* when disabled. Throws 401 for every failure (missing/malformed params, mismatch,
* expiry). Tries the active secret then the secondary.
*
* Excludes mutable query params (Azure `blockid`, `comp=blocklist`), so one
* `CreateCacheEntry`-issued signature validates every chunk PUT and the final
* blocklist PUT within the TTL.
*/
export function verifySignedRequest(
event: H3Event,
canonicalPath: string,
config: UrlSigningConfig,
): void {
if (!config.enabled) return

const query = getQuery(event)
const exp = query.exp
const sig = query.sig

// Validate before any HMAC/expiry work.
if (
typeof exp !== 'string' || // reject array-valued exp (repeated query param)
typeof sig !== 'string' || // reject array-valued sig (repeated query param)
!/^[1-9]\d*$/.test(exp) || // digits only, no leading zeros so exp round-trips through Number()
sig.length === 0 // reject empty sig
)
throw createError({ statusCode: 401 })

const expMs = Number(exp)
if (!Number.isSafeInteger(expMs) || expMs <= 0) throw createError({ statusCode: 401 })

const provided = Buffer.from(sig, 'base64url')
const matched = verifySecrets(config).some((secret) => {
const expected = Buffer.from(computeSignature(canonicalPath, expMs, secret), 'base64url')
return expected.length === provided.length && timingSafeEqual(expected, provided)
})
if (!matched) throw createError({ statusCode: 401 })

if (Date.now() > expMs) throw createError({ statusCode: 401 })
}
9 changes: 6 additions & 3 deletions plugins/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { getDatabase } from '~/lib/db'
import { env } from '~/lib/env'
import { logger } from '~/lib/logger'
import { getStorage } from '~/lib/storage'
import { redactSignedPath } from '~/lib/url-signing'

const logPath = (event: { path: string }) => redactSignedPath(event.path)

export default defineNitroPlugin(async (nitro) => {
const version = useRuntimeConfig().version
Expand Down Expand Up @@ -47,17 +50,17 @@ export default defineNitroPlugin(async (nitro) => {
}

logger.error(
`Response: ${event.method} ${event.path} > ${error instanceof H3Error ? error.statusCode : '[no status code]'}\n`,
`Response: ${event.method} ${logPath(event)} > ${error instanceof H3Error ? error.statusCode : '[no status code]'}\n`,
error,
)
})

if (env.DEBUG) {
nitro.hooks.hook('request', (event) => {
logger.debug(`Request: ${event.method} ${event.path}`)
logger.debug(`Request: ${event.method} ${logPath(event)}`)
})
nitro.hooks.hook('afterResponse', (event) => {
logger.debug(`Response: ${event.method} ${event.path} > ${getResponseStatus(event)}`)
logger.debug(`Response: ${event.method} ${logPath(event)} > ${getResponseStatus(event)}`)
})
}

Expand Down
8 changes: 7 additions & 1 deletion routes/[...path].ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { env } from '~/lib/env'
import { logger } from '~/lib/logger'
import { redactSignedPath } from '~/lib/url-signing'

export default defineEventHandler(async (event) => {
logger.debug('proxying unknown path', event.path, 'to', env.DEFAULT_ACTIONS_RESULTS_URL)
logger.debug(
'proxying unknown path',
redactSignedPath(event.path),
'to',
env.DEFAULT_ACTIONS_RESULTS_URL,
)
return proxyRequest(event, `${env.DEFAULT_ACTIONS_RESULTS_URL}${event.path}`)
})
5 changes: 5 additions & 0 deletions routes/devstoreaccount1/upload/[uploadId].put.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@ import { z } from 'zod'
import { logger } from '~/lib/logger'

import { getStorage } from '~/lib/storage'
import { urlSigningConfigFromEnv, verifySignedRequest } from '~/lib/url-signing'

const pathParamsSchema = z.object({
uploadId: z.coerce.number(),
})

export default defineEventHandler(async (event) => {
// Verify before the comp=blocklist short-circuit so finalization is protected too.
// Use the raw param (not the zod-coerced number) to match the signed canonical path.
verifySignedRequest(event, `/upload/${event.context.params?.uploadId}`, urlSigningConfigFromEnv())

const parsedPathParams = pathParamsSchema.safeParse(event.context.params)
if (!parsedPathParams.success)
throw createError({
Expand Down
3 changes: 3 additions & 0 deletions routes/download/[cacheEntryId].ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Readable } from 'node:stream'
import { z } from 'zod'
import { logger } from '~/lib/logger'
import { getStorage } from '~/lib/storage'
import { urlSigningConfigFromEnv, verifySignedRequest } from '~/lib/url-signing'

const pathParamsSchema = z.object({
cacheEntryId: z.string(),
Expand All @@ -17,6 +18,8 @@ export default defineEventHandler(async (event) => {

const { cacheEntryId } = parsedPathParams.data

verifySignedRequest(event, `/download/${cacheEntryId}`, urlSigningConfigFromEnv())

const storage = await getStorage()
const stream = await storage.download(cacheEntryId)
if (!stream)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { env } from '~/lib/env'
import { getCacheScope } from '~/lib/scope'
import { getStorage } from '~/lib/storage'
import { readTwirpRequest, sendTwirpResponse, TwirpMessage } from '~/lib/twirp'
import { signQuery, urlSigningConfigFromEnv } from '~/lib/url-signing'

const bodySchema = z.object({
key: z.string().min(1),
Expand All @@ -28,7 +29,15 @@ export default defineEventHandler(async (event) => {
return sendTwirpResponse(
event,
upload
? { ok: true, signed_upload_url: `${env.API_BASE_URL}/devstoreaccount1/upload/${upload.id}` }
? {
ok: true,
// Emitted path is /devstoreaccount1/upload/{id}; signed canonical path
// is the invariant /upload/{id} (see signQuery).
signed_upload_url: `${env.API_BASE_URL}/devstoreaccount1/upload/${upload.id}${signQuery(
`/upload/${upload.id}`,
urlSigningConfigFromEnv(),
)}`,
}
: { ok: false },
TwirpMessage.CreateCacheEntryResponse,
)
Expand Down
1 change: 1 addition & 0 deletions tests/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const TESTING_ENV_BASE = {
| 'BENCHMARK'
| 'SKIP_TOKEN_VALIDATION'
| 'ACTIONS_TOKEN_ISSUER'
| 'URL_SIGNING_ENABLED'
> &
Record<string, string>

Expand Down
Loading