fix(sap_s4hana): preserve raw Set-Cookie array for CSRF cookie join

SecureFetchHeaders previously collapsed multi-value Set-Cookie headers
with ", ", forcing consumers to re-split via a fragile regex. Cookie
values containing "=" or "," (e.g., Base64 session tokens) could be
misparsed and produce malformed Cookie strings on CSRF-protected
mutations.

Add SecureFetchHeaders.getSetCookie() that returns the raw array, and
update the S/4HANA OData proxy's joinSetCookies to consume it directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Waleed Latif
2026-05-06 18:15:16 -07:00
co-authored by Claude Opus 4.7
parent d7c9f9cc53
commit d767d76dd2
2 changed files with 24 additions and 7 deletions
@@ -113,8 +113,8 @@ interface CsrfBundle {
}
function joinSetCookies(response: SecureFetchResponse): string {
const cookies = (response.headers.get('set-cookie') ?? '').split(/,\s*(?=[^=,;\s]+=)/)
return cookies
return response.headers
.getSetCookie()
.map((c) => c.split(';')[0]?.trim())
.filter(Boolean)
.join('; ')
@@ -217,15 +217,22 @@ export interface SecureFetchOptions {
export class SecureFetchHeaders {
private headers: Map<string, string>
private setCookies: string[]
constructor(headers: Record<string, string>) {
constructor(headers: Record<string, string>, setCookies: string[] = []) {
this.headers = new Map(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]))
this.setCookies = setCookies
}
get(name: string): string | null {
return this.headers.get(name.toLowerCase()) ?? null
}
/** Returns the raw `Set-Cookie` header values as an array. Each entry is one cookie. */
getSetCookie(): string[] {
return [...this.setCookies]
}
toRecord(): Record<string, string> {
const record: Record<string, string> = {}
for (const [key, value] of this.headers) {
@@ -384,11 +391,21 @@ export async function secureFetchWithPinnedIP(
const bodyBuffer = Buffer.concat(chunks)
const body = bodyBuffer.toString('utf-8')
const headersRecord: Record<string, string> = {}
let setCookieArray: string[] = []
for (const [key, value] of Object.entries(res.headers)) {
if (typeof value === 'string') {
headersRecord[key.toLowerCase()] = value
const lowerKey = key.toLowerCase()
if (lowerKey === 'set-cookie') {
if (Array.isArray(value)) {
setCookieArray = value
headersRecord[lowerKey] = value.join(', ')
} else if (typeof value === 'string') {
setCookieArray = [value]
headersRecord[lowerKey] = value
}
} else if (typeof value === 'string') {
headersRecord[lowerKey] = value
} else if (Array.isArray(value)) {
headersRecord[key.toLowerCase()] = value.join(', ')
headersRecord[lowerKey] = value.join(', ')
}
}
@@ -396,7 +413,7 @@ export async function secureFetchWithPinnedIP(
ok: statusCode >= 200 && statusCode < 300,
status: statusCode,
statusText: res.statusMessage || '',
headers: new SecureFetchHeaders(headersRecord),
headers: new SecureFetchHeaders(headersRecord, setCookieArray),
text: async () => body,
json: async () => JSON.parse(body),
arrayBuffer: async () =>