Skip to content
Merged
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- **BREAKING:** `UrlGuard` now requires HTTPS for allowed hosts. Plain-HTTP `apiUrl`, `downloadUrl`, or `s3ApiUrl` values are rejected. Supply an HTTPS URL, or use a custom transport to bypass the guard.
- **`B2Client.accountInfo` is now a prototype accessor.** It is no longer an
own property, so `{ ...client }`, `Object.keys(client)`,
`Object.assign({}, client)`, `Object.hasOwn(client, 'accountInfo')`, and
`Object.getOwnPropertyDescriptor(client, 'accountInfo')` no longer see it.
Assigning `client.accountInfo` throws in strict mode, and a subclass
redeclaring `accountInfo` as a field fails with TypeScript `TS2610`.
`client.accountInfo` and `'accountInfo' in client` still work. Read
`client.accountInfo` directly, and pass a custom store through the
constructor's `accountInfo` option.

### Security

- **SDK objects hide application keys and authorization tokens in diagnostics.**
Logging, inspecting, or serializing clients, bucket and file handles, auth
stores, and upload URL pools no longer prints stored credentials. Explicit
`authorize()` and `getAuth()` responses still contain the real token, and file
auth caches still persist it.
Methods called through a `Proxy` wrapper (for example Vue `reactive(client)`)
now throw `Cannot read private member`, the same constraint as the Partner
client. Keep these SDK instances outside
reactive proxy wrappers.

- Updated transitive development dependencies to patched versions.

### Fixed
Expand Down
70 changes: 47 additions & 23 deletions src/auth/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,12 @@ function authEndpointsMatchRealm(auth: AuthorizeAccountResponse, realmUrl: strin
}
}

/** Safe diagnostic summary emitted by {@link FileAccountInfo.toJSON}. */
export interface FileAccountInfoJson {
/** Whether an authorization response is currently stored. */
readonly authorized: boolean
}

/**
* Node-only {@link AccountInfo} backend that persists the authorization
* response to a JSON file. Upload URL pools remain in memory because URLs are
Expand All @@ -194,7 +200,7 @@ function authEndpointsMatchRealm(auth: AuthorizeAccountResponse, realmUrl: strin
* This module imports `node:fs/promises`; do not load it in browser bundles.
*/
export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo {
private readonly inner = new InMemoryAccountInfo()
readonly #inner = new InMemoryAccountInfo()
private writeQueue: Promise<void> = Promise.resolve()
private realmUrl: string | undefined
private loadedAuthRealmUrl: string | null | undefined
Expand Down Expand Up @@ -226,7 +232,7 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
const text = await readFile(this.path, 'utf8')
const parsed = readPersistedAuthState(JSON.parse(text) as unknown)
if (parsed === null) return
this.inner.setAuth(parsed.auth)
this.#inner.setAuth(parsed.auth)
this.loadedAuthRealmUrl = parsed.realmUrl
this.loadedAuthApplicationKeyId = parsed.applicationKeyId
this.discardMismatchedAuth()
Expand Down Expand Up @@ -260,13 +266,13 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
}

private discardMismatchedAuth(): void {
const auth = this.inner.getAuth()
const auth = this.#inner.getAuth()
if (auth === null) return

const reason = this.mismatchReason(auth)
if (reason === null) return

this.inner.clear()
this.#inner.clear()
this.options.onDiscard?.({
path: this.path,
reason,
Expand Down Expand Up @@ -305,7 +311,7 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
* @returns A promise that resolves when the write completes.
*/
private flush(): Promise<void> {
const auth = this.inner.getAuth()
const auth = this.#inner.getAuth()
const next = this.writeQueue.then(async () => {
if (auth === null) {
try {
Expand Down Expand Up @@ -358,7 +364,7 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
* @param auth - The authorize-account response to persist.
*/
setAuth(auth: AuthorizeAccountResponse): void {
this.inner.setAuth(auth)
this.#inner.setAuth(auth)
this.loadedAuthRealmUrl = this.realmUrl ?? null
this.loadedAuthApplicationKeyId = this.applicationKeyId ?? null
void this.flush()
Expand All @@ -370,12 +376,12 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
* @returns The cached authorization response, or `null` if not authorized.
*/
getAuth(): AuthorizeAccountResponse | null {
return this.inner.getAuth()
return this.#inner.getAuth()
}

/** Discards in-memory state and clears the on-disk file. */
clear(): void {
this.inner.clear()
this.#inner.clear()
this.loadedAuthRealmUrl = undefined
this.loadedAuthApplicationKeyId = undefined
void this.flush()
Expand All @@ -387,7 +393,7 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
* @returns The base URL for B2 API calls.
*/
getApiUrl(): string {
return this.inner.getApiUrl()
return this.#inner.getApiUrl()
}

/**
Expand All @@ -396,7 +402,7 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
* @returns The base URL for file downloads.
*/
getDownloadUrl(): string {
return this.inner.getDownloadUrl()
return this.#inner.getDownloadUrl()
}

/**
Expand All @@ -405,7 +411,7 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
* @returns The current authorization token.
*/
getAuthToken(): string {
return this.inner.getAuthToken()
return this.#inner.getAuthToken()
}

/**
Expand All @@ -414,7 +420,7 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
* @returns The authorized account ID.
*/
getAccountId(): string {
return this.inner.getAccountId()
return this.#inner.getAccountId()
}

/**
Expand All @@ -423,7 +429,7 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
* @returns The recommended part size in bytes.
*/
getRecommendedPartSize(): number {
return this.inner.getRecommendedPartSize()
return this.#inner.getRecommendedPartSize()
}

/**
Expand All @@ -432,7 +438,7 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
* @returns The absolute minimum part size in bytes.
*/
getAbsoluteMinimumPartSize(): number {
return this.inner.getAbsoluteMinimumPartSize()
return this.#inner.getAbsoluteMinimumPartSize()
}

/**
Expand All @@ -441,7 +447,7 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
* @returns The base URL for the S3-compatible API.
*/
getS3ApiUrl(): string {
return this.inner.getS3ApiUrl()
return this.#inner.getS3ApiUrl()
}

/**
Expand All @@ -450,7 +456,7 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
* @returns The restricted bucket ID, or `null` if the key is unrestricted.
*/
getAllowedBucketId(): BucketId | null {
return this.inner.getAllowedBucketId()
return this.#inner.getAllowedBucketId()
}

/**
Expand All @@ -459,7 +465,7 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
* @returns The restricted bucket IDs, or `null` if the key is unrestricted.
*/
getAllowedBucketIds(): readonly BucketId[] | null {
return this.inner.getAllowedBucketIds()
return this.#inner.getAllowedBucketIds()
}

/**
Expand All @@ -470,7 +476,7 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
* @returns A reusable upload URL entry, or `null` if none are pooled.
*/
checkoutUploadUrl(bucketId: BucketId): UploadUrlEntry | null {
return this.inner.checkoutUploadUrl(bucketId)
return this.#inner.checkoutUploadUrl(bucketId)
}

/**
Expand All @@ -480,7 +486,7 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
* @param entry - Upload URL entry to return.
*/
returnUploadUrl(bucketId: BucketId, entry: UploadUrlEntry): void {
this.inner.returnUploadUrl(bucketId, entry)
this.#inner.returnUploadUrl(bucketId, entry)
}

/**
Expand All @@ -490,7 +496,7 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
* @param entry - Upload URL entry to evict.
*/
evictUploadUrl(bucketId: BucketId, entry: UploadUrlEntry): void {
this.inner.evictUploadUrl(bucketId, entry)
this.#inner.evictUploadUrl(bucketId, entry)
}

/**
Expand All @@ -501,7 +507,7 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
* @returns A reusable part upload URL entry, or `null` if none are pooled.
*/
checkoutPartUploadUrl(fileId: string): UploadUrlEntry | null {
return this.inner.checkoutPartUploadUrl(fileId)
return this.#inner.checkoutPartUploadUrl(fileId)
}

/**
Expand All @@ -511,7 +517,7 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
* @param entry - Part upload URL entry to return.
*/
returnPartUploadUrl(fileId: string, entry: UploadUrlEntry): void {
this.inner.returnPartUploadUrl(fileId, entry)
this.#inner.returnPartUploadUrl(fileId, entry)
}

/**
Expand All @@ -521,7 +527,25 @@ export class FileAccountInfo implements AccountInfo, AuthContextAwareAccountInfo
* @param entry - Part upload URL entry to evict.
*/
evictPartUploadUrl(fileId: string, entry: UploadUrlEntry): void {
this.inner.evictPartUploadUrl(fileId, entry)
this.#inner.evictPartUploadUrl(fileId, entry)
}

/**
* Hides authorization and upload tokens from JSON diagnostics.
*
* @returns Whether authorization state is stored, without walking it.
*/
toJSON(): FileAccountInfoJson {
return { authorized: this.#inner.getAuth() !== null }
}

/**
* Hides authorization and upload tokens from Node's `util.inspect`.
*
* @returns The same safe summary used by JSON diagnostics.
*/
[Symbol.for('nodejs.util.inspect.custom')](): FileAccountInfoJson {
return this.toJSON()
}

/**
Expand Down
Loading
Loading