Skip to content

Commit b769a15

Browse files
committed
fix(agiloft): parse the raw login body and keep the username out of logs
Review round 4. The previous round redacted the login response before parsing it. A token is opaque base64, so a short credential can appear inside one by coincidence - a three-character password is near certain to - and redacting first rewrote the token, breaking bearer auth for every alrest operation. Parsing now stays on the raw text and only the failure messages use the redacted copy, which is the split the shared alrest reader already had. The post-transmit create handler recorded the Agiloft username in structured logs. It was added so the "check the table" instruction had something to search on, but the login is half of a credential pair and whoever reads that log already knows which account the block is configured with. It is gone, and the logged error message now goes through the same redaction as the one returned to the caller, since it can carry echoed upstream text. The regression test builds a token with the password inside it and asserts the token comes back byte for byte.
1 parent e9adee6 commit b769a15

3 files changed

Lines changed: 54 additions & 9 deletions

File tree

apps/sim/app/api/tools/agiloft/create_record/route.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
168168
const declined = AGILOFT_EXCEPTION.test(described)
169169
logger.error(`[${requestId}] Agiloft create returned no record ID`, {
170170
table: params.table,
171-
login: params.login,
172171
fields: Object.keys(fieldValues),
173172
declined,
174173
})
@@ -219,10 +218,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
219218
* 500 is what would have the caller retry and duplicate the record, which
220219
* is the failure this operation exists to prevent.
221220
*/
221+
/**
222+
* The message goes through the same redaction the caller-facing error
223+
* does: it can carry upstream text, and the credentials travel in this
224+
* request's body. The login is not recorded at all - whoever reads this
225+
* already knows which account the block is configured with, and it is
226+
* half of a credential pair.
227+
*/
222228
logger.error(`[${requestId}] Agiloft create failed after the request was sent`, {
223-
error,
229+
error: redactAgiloftSecrets(toError(error).message, params),
224230
table: params.table,
225-
login: params.login,
226231
})
227232

228233
return NextResponse.json({

apps/sim/tools/agiloft/utils.server.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ vi.mock('@/lib/core/security/input-validation.server', () => ({
1616
secureFetchWithPinnedIP: mockSecureFetch,
1717
}))
1818

19-
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
19+
import { agiloftLoginPinned, executeAgiloftRequest } from '@/tools/agiloft/utils.server'
2020

2121
const baseParams = {
2222
instanceUrl: 'https://example.agiloft.com',
@@ -166,3 +166,36 @@ describe('executeAgiloftRequest', () => {
166166
expect(mockSecureFetch).not.toHaveBeenCalled()
167167
})
168168
})
169+
170+
describe('agiloftLoginPinned token integrity', () => {
171+
/**
172+
* A token is opaque base64, so a short credential can appear inside it by
173+
* coincidence. Redacting the body before parsing would rewrite the token and
174+
* break every request that carries it, so parsing stays on the raw text.
175+
*/
176+
it('returns the token intact when the password occurs inside it', async () => {
177+
const password = 'abc'
178+
const token = `eyJhbGciOiJIUzI1NiJ9.${password}payload.signature`
179+
180+
mockSecureFetch.mockResolvedValueOnce(
181+
mockResponse({ json: { access_token: token, authentication_scheme: 'Bearer ' } })
182+
)
183+
184+
const session = await agiloftLoginPinned({ ...baseParams, password }, '93.184.216.34')
185+
186+
expect(session.token).toBe(token)
187+
expect(session.authorization).toBe(`Bearer ${token}`)
188+
})
189+
190+
it('redacts the credentials from a login failure message', async () => {
191+
mockSecureFetch.mockResolvedValueOnce(
192+
mockResponse({
193+
ok: false,
194+
status: 500,
195+
text: `<html><body>Error: $login=admin&$password=${PLACEHOLDER_PASSWORD}</body></html>`,
196+
})
197+
)
198+
199+
await expect(agiloftLoginPinned(baseParams, '93.184.216.34')).rejects.toThrow(/\[redacted\]/)
200+
})
201+
})

apps/sim/tools/agiloft/utils.server.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,26 +109,33 @@ export async function agiloftLoginPinned(
109109
),
110110
})
111111

112+
const rawText = await response.text()
113+
112114
/**
113115
* Login posts the credentials in its form body, so an error page echoing the
114116
* submitted parameters echoes them. Redacted while the text is still whole,
115117
* before any of the messages below truncate it.
118+
*
119+
* Only the messages use this. Parsing stays on `rawText`: a token is opaque
120+
* base64, so a short credential can appear inside it by coincidence, and
121+
* redacting first would rewrite the token and break every request that
122+
* carries it.
116123
*/
117-
const text = redactAgiloftSecrets(await response.text(), params)
124+
const safeText = redactAgiloftSecrets(rawText, params)
118125

119126
if (!response.ok) {
120-
throw new Error(`Agiloft login failed (${response.status}): ${describeAgiloftError(text)}`)
127+
throw new Error(`Agiloft login failed (${response.status}): ${describeAgiloftError(safeText)}`)
121128
}
122129

123130
let data: { access_token?: string; authentication_scheme?: string }
124131
try {
125-
data = JSON.parse(text)
132+
data = JSON.parse(rawText)
126133
} catch {
127-
throw new Error(`Agiloft login returned a non-JSON response: ${truncate(text, 200)}`)
134+
throw new Error(`Agiloft login returned a non-JSON response: ${truncate(safeText, 200)}`)
128135
}
129136

130137
if (!data.access_token) {
131-
throw new Error(`Agiloft login did not return an access token: ${truncate(text, 200)}`)
138+
throw new Error(`Agiloft login did not return an access token: ${truncate(safeText, 200)}`)
132139
}
133140

134141
const scheme = (data.authentication_scheme || 'Bearer').trim() || 'Bearer'

0 commit comments

Comments
 (0)