Skip to content

Commit 1444787

Browse files
committed
fix(oauth): write disk-cached tokens with mode 0600 (current-user only)
Cached OAuth bearer tokens are confidentiality-class secrets — anyone holding one can call Camunda APIs as the principal until expiry. Previously OAuthProvider wrote the cache files via fs.writeFile() without specifying a mode, so they inherited the process umask (typically 0644 on Linux/macOS), making them readable by other local users. The cache directory itself was created with default mkdir mode (0755). Changes: - Cache directory: created with mode 0700; if it already exists with group/other bits set, those bits are stripped (user bits are never widened). - Token files: written with {mode: 0o600}; existing files are tightened the same way on read and on subsequent writes. - Windows is unaffected — Node largely ignores the mode argument there, and per-user profile ACLs already restrict access. Class-of-defect regression test in OAuthProvider.cache-file-permissions.unit.spec.ts asserts on every file in cacheDir (not a single named path), so future code paths that write tokens through a different filename or fs API are still caught. Red against pre-fix code, green after. Closes #737
1 parent 3e4ec5c commit 1444787

2 files changed

Lines changed: 281 additions & 0 deletions

File tree

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import fs from 'fs'
2+
import http from 'http'
3+
import { AddressInfo } from 'net'
4+
import os from 'os'
5+
import path from 'path'
6+
7+
import jwt from 'jsonwebtoken'
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9+
10+
import { EnvironmentSetup } from '../../lib/EnvironmentSetup'
11+
12+
/**
13+
* Class-of-defect regression tests for issue #737: OAuthProvider must write
14+
* its on-disk token cache so it is readable only by the current user.
15+
*
16+
* Cached OAuth bearer tokens are confidentiality-class secrets (anyone
17+
* holding one can call Camunda APIs as the principal until expiry). Files
18+
* written under the cache dir must therefore have POSIX mode bits with no
19+
* group or other access (mode & 0o077 === 0). The cache dir itself must be
20+
* similarly restricted so an attacker cannot list and target the files.
21+
*
22+
* The breadth of the assertion (every file in cacheDir, not one named path)
23+
* is intentional: it catches future code paths that write tokens through a
24+
* different filename or via another fs API.
25+
*
26+
* POSIX-only: skipped on Windows because Node largely ignores the mode
27+
* argument there.
28+
*/
29+
30+
const skipOnWindows = process.platform === 'win32'
31+
32+
let tmpDirs: string[] = []
33+
34+
beforeEach(() => {
35+
vi.resetModules()
36+
EnvironmentSetup.wipeEnv()
37+
})
38+
39+
afterEach(() => {
40+
for (const dir of tmpDirs) {
41+
try {
42+
fs.rmSync(dir, { recursive: true, force: true })
43+
} catch {
44+
/* ignore */
45+
}
46+
}
47+
tmpDirs = []
48+
})
49+
50+
function makeTmpDirPath(prefix: string): string {
51+
// Reserve a unique name but do NOT create the directory — we want to
52+
// observe how OAuthProvider creates it.
53+
const dir = path.join(
54+
os.tmpdir(),
55+
`${prefix}${Date.now()}-${Math.random().toString(36).slice(2)}`
56+
)
57+
tmpDirs.push(dir)
58+
return dir
59+
}
60+
61+
function startTokenServer(): {
62+
url: string
63+
close: () => Promise<void>
64+
} {
65+
const server = http.createServer((req, res) => {
66+
if (req.method !== 'POST') {
67+
res.statusCode = 405
68+
return res.end()
69+
}
70+
let body = ''
71+
req.on('data', (chunk) => {
72+
body += chunk.toString()
73+
})
74+
req.on('end', () => {
75+
const audienceMatch = /audience=([^&]+)/.exec(body)
76+
const audience = audienceMatch ? audienceMatch[1] : ''
77+
const token = jwt.sign({ id: 1, aud: audience }, 'test-secret', {
78+
expiresIn: 60,
79+
})
80+
res.writeHead(200, { 'Content-Type': 'application/json' })
81+
res.end(JSON.stringify({ access_token: token, expires_in: 60 }))
82+
})
83+
})
84+
server.listen()
85+
const port = (server.address() as AddressInfo).port
86+
return {
87+
url: `http://127.0.0.1:${port}`,
88+
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
89+
}
90+
}
91+
92+
async function waitForFile(file: string, timeoutMs = 2000): Promise<void> {
93+
const start = Date.now()
94+
while (Date.now() - start < timeoutMs) {
95+
if (fs.existsSync(file)) return
96+
await new Promise((r) => setTimeout(r, 25))
97+
}
98+
throw new Error(`Timed out waiting for ${file}`)
99+
}
100+
101+
describe.skipIf(skipOnWindows)(
102+
'OAuthProvider on-disk cache file permissions (#737)',
103+
() => {
104+
it('creates the cache directory with no group/other access', async () => {
105+
const { OAuthProvider } = (await vi.importActual('../../oauth')) as {
106+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
107+
OAuthProvider: any
108+
}
109+
const cacheDir = makeTmpDirPath('oauth-perms-mkdir-')
110+
111+
new OAuthProvider({
112+
config: {
113+
CAMUNDA_OAUTH_URL: 'http://127.0.0.1:1',
114+
ZEEBE_CLIENT_ID: 'zeebe-client',
115+
ZEEBE_CLIENT_SECRET: 'zeebe-secret',
116+
CAMUNDA_TOKEN_CACHE_DIR: cacheDir,
117+
},
118+
})
119+
120+
expect(fs.existsSync(cacheDir)).toBe(true)
121+
const dirMode = fs.statSync(cacheDir).mode
122+
expect(
123+
dirMode & 0o077,
124+
`cacheDir ${cacheDir} must not be accessible to group/other (mode=${(
125+
dirMode & 0o777
126+
).toString(8)})`
127+
).toBe(0)
128+
})
129+
130+
it('writes every cached token file with no group/other access', async () => {
131+
const { OAuthProvider } = (await vi.importActual('../../oauth')) as {
132+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
133+
OAuthProvider: any
134+
}
135+
const cacheDir = makeTmpDirPath('oauth-perms-write-')
136+
const server = startTokenServer()
137+
138+
try {
139+
const o = new OAuthProvider({
140+
config: {
141+
CAMUNDA_OAUTH_URL: server.url,
142+
ZEEBE_CLIENT_ID: 'zeebe-client',
143+
ZEEBE_CLIENT_SECRET: 'zeebe-secret',
144+
CAMUNDA_CONSOLE_CLIENT_ID: 'console-client',
145+
CAMUNDA_CONSOLE_CLIENT_SECRET: 'console-secret',
146+
CAMUNDA_CONSOLE_OAUTH_AUDIENCE: 'api.cloud.camunda.io',
147+
CAMUNDA_ZEEBE_OAUTH_AUDIENCE: 'zeebe.camunda.io',
148+
CAMUNDA_TOKEN_CACHE_DIR: cacheDir,
149+
},
150+
})
151+
;(o as unknown as { isCamundaSaaS: boolean }).isCamundaSaaS = true
152+
153+
await o.getHeaders('ZEEBE')
154+
await o.getHeaders('CONSOLE')
155+
156+
// sendToFileCache writes asynchronously via fs.writeFile.
157+
const expectedZeebe = path.join(
158+
cacheDir,
159+
'oauth-token-zeebe-client-ZEEBE.json'
160+
)
161+
const expectedConsole = path.join(
162+
cacheDir,
163+
'oauth-token-console-client-CONSOLE.json'
164+
)
165+
await waitForFile(expectedZeebe)
166+
await waitForFile(expectedConsole)
167+
168+
const tokenFiles = fs
169+
.readdirSync(cacheDir)
170+
.filter((f) => f.startsWith('oauth-token-') && f.endsWith('.json'))
171+
172+
expect(tokenFiles.length).toBeGreaterThanOrEqual(2)
173+
174+
for (const f of tokenFiles) {
175+
const full = path.join(cacheDir, f)
176+
const mode = fs.statSync(full).mode
177+
expect(
178+
mode & 0o077,
179+
`token file ${f} must not be accessible to group/other (mode=${(
180+
mode & 0o777
181+
).toString(8)})`
182+
).toBe(0)
183+
}
184+
} finally {
185+
await server.close()
186+
}
187+
})
188+
189+
it('tightens permissions on a pre-existing world-readable token file', async () => {
190+
const { OAuthProvider } = (await vi.importActual('../../oauth')) as {
191+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
192+
OAuthProvider: any
193+
}
194+
const cacheDir = makeTmpDirPath('oauth-perms-existing-')
195+
fs.mkdirSync(cacheDir, { recursive: true, mode: 0o755 })
196+
197+
// Simulate a token file written by an older SDK with default umask.
198+
const stale = path.join(cacheDir, 'oauth-token-zeebe-client-ZEEBE.json')
199+
fs.writeFileSync(
200+
stale,
201+
JSON.stringify({
202+
access_token: 'stale',
203+
expires_in: 60,
204+
expiry: Math.floor(Date.now() / 1000) + 60,
205+
}),
206+
{ mode: 0o644 }
207+
)
208+
// Sanity: confirm we set up the bad mode the test wants to fix.
209+
expect(fs.statSync(stale).mode & 0o077).not.toBe(0)
210+
211+
const server = startTokenServer()
212+
try {
213+
const o = new OAuthProvider({
214+
config: {
215+
CAMUNDA_OAUTH_URL: server.url,
216+
ZEEBE_CLIENT_ID: 'zeebe-client',
217+
ZEEBE_CLIENT_SECRET: 'zeebe-secret',
218+
CAMUNDA_TOKEN_CACHE_DIR: cacheDir,
219+
},
220+
})
221+
await o.getHeaders('ZEEBE')
222+
await waitForFile(stale)
223+
224+
const mode = fs.statSync(stale).mode
225+
expect(
226+
mode & 0o077,
227+
`pre-existing token file must be tightened (mode=${(
228+
mode & 0o777
229+
).toString(8)})`
230+
).toBe(0)
231+
} finally {
232+
await server.close()
233+
}
234+
})
235+
}
236+
)

src/oauth/lib/OAuthProvider.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,25 @@ export class OAuthProvider implements IHeadersProvider {
195195
if (this.useFileCache) {
196196
try {
197197
if (!fs.existsSync(this.cacheDir)) {
198+
// Mode 0o700: only the current user may read, write, or list the
199+
// cached token files. See #737. (POSIX-only; Node ignores mode on
200+
// Windows, where per-user profile ACLs already restrict access.)
198201
fs.mkdirSync(this.cacheDir, {
199202
recursive: true,
203+
mode: 0o700,
200204
})
205+
} else if (process.platform !== 'win32') {
206+
// Tighten an existing cache directory that was created by an older
207+
// SDK version (or by another process) with a wider mode. Only
208+
// strip group/other bits — never widen the user's existing mode.
209+
try {
210+
const current = fs.statSync(this.cacheDir).mode
211+
if (current & 0o077) {
212+
fs.chmodSync(this.cacheDir, current & ~0o077)
213+
}
214+
} catch (_) {
215+
/* best-effort */
216+
}
201217
}
202218
// Try to write a temporary file to the directory
203219
const tempfilename = path.join(this.cacheDir, `${randomUUID()}.tmp`)
@@ -564,6 +580,19 @@ export class OAuthProvider implements IHeadersProvider {
564580
}
565581
try {
566582
trace(`Reading file cached token for ${audience}`)
583+
// If the file was created by an older SDK with a wider mode (e.g.
584+
// 0o644), tighten it now. Only strip group/other bits — never widen
585+
// the user's existing mode. POSIX-only. See #737.
586+
if (process.platform !== 'win32') {
587+
try {
588+
const current = fs.statSync(tokenFileName).mode
589+
if (current & 0o077) {
590+
fs.chmodSync(tokenFileName, current & ~0o077)
591+
}
592+
} catch (_) {
593+
/* best-effort */
594+
}
595+
}
567596
token = JSON.parse(fs.readFileSync(tokenFileName, 'utf8'))
568597
trace(`Retrieved token from file cache`)
569598
if (this.isExpired(token)) {
@@ -613,9 +642,25 @@ export class OAuthProvider implements IHeadersProvider {
613642
...token,
614643
expiry: decoded.exp ?? 0,
615644
}),
645+
// Mode 0o600: bearer tokens are confidentiality-class secrets and
646+
// must not be readable by other local users. See #737.
647+
{ mode: 0o600 },
616648
(e) => {
617649
if (!e) {
618650
trace(`Wrote OAuth token to file ${file}`)
651+
// If the file already existed with a wider mode (older SDK or
652+
// external process), {mode} on writeFile only affects creation.
653+
// Strip group/other bits without widening user bits. POSIX-only.
654+
if (process.platform !== 'win32') {
655+
try {
656+
const current = fs.statSync(file).mode
657+
if (current & 0o077) {
658+
fs.chmodSync(file, current & ~0o077)
659+
}
660+
} catch (_) {
661+
/* best-effort */
662+
}
663+
}
619664
return
620665
}
621666
this.log.error(`Error writing OAuth token to file ${file}`)

0 commit comments

Comments
 (0)