Skip to content

Commit f2a22b0

Browse files
Fix New-line characters are not allowed in header values (#209) (#213)
1 parent d1f9f43 commit f2a22b0

2 files changed

Lines changed: 197 additions & 2 deletions

File tree

src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,8 @@ private async Task<bool> EnsureGraphHeadersAsync(string tenantId, CancellationTo
224224
_logger.LogDebug("Successfully acquired Graph token via Azure CLI");
225225
}
226226

227-
// Trim token to remove any newline characters that may cause header validation errors
228-
token = token.Trim();
227+
// Remove all newline characters and trim whitespace to prevent header validation errors
228+
token = token.ReplaceLineEndings(string.Empty).Trim();
229229

230230
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
231231
// NOTE: Do NOT add "ConsistencyLevel: eventual" header here.

src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,201 @@ public async Task LookupServicePrincipalAsync_DoesNotIncludeConsistencyLevelHead
192192
"ConsistencyLevel header should NOT be present for simple service principal lookup queries. " +
193193
"This header is only needed for advanced Graph query capabilities and causes HTTP 400 errors otherwise.");
194194
}
195+
196+
[Theory]
197+
[InlineData("token-with-trailing-newline\n")]
198+
[InlineData("token-with-trailing-crlf\r\n")]
199+
[InlineData("token\nwith\nembedded\nnewlines")]
200+
[InlineData("token\r\nwith\r\nembedded\r\ncrlf")]
201+
[InlineData("token\rwith\rcarriage\rreturns")]
202+
[InlineData("\nleading-newline-token")]
203+
[InlineData("\r\nleading-crlf-token")]
204+
[InlineData(" token-with-whitespace \n")]
205+
public async Task GraphGetAsync_SanitizesTokenWithNewlineCharacters(string tokenWithNewlines)
206+
{
207+
// This test verifies that tokens containing newline characters (\r, \n, \r\n)
208+
// are properly sanitized before being used in HTTP Authorization headers.
209+
// Without this fix, System.FormatException is thrown:
210+
// "New-line characters are not allowed in header values."
211+
// Regression test for newline character issue in token handling.
212+
213+
// Arrange
214+
HttpRequestMessage? capturedRequest = null;
215+
var handler = new CapturingHttpMessageHandler((req) => capturedRequest = req);
216+
var logger = Substitute.For<ILogger<GraphApiService>>();
217+
var executor = Substitute.For<CommandExecutor>(Substitute.For<ILogger<CommandExecutor>>());
218+
219+
// Mock az CLI to return a token WITH newline characters (simulating real-world issue)
220+
executor.ExecuteAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
221+
.Returns(callInfo =>
222+
{
223+
var cmd = callInfo.ArgAt<string>(0);
224+
var args = callInfo.ArgAt<string>(1);
225+
226+
if (cmd == "az" && args != null && args.StartsWith("account show", StringComparison.OrdinalIgnoreCase))
227+
{
228+
return Task.FromResult(new CommandResult
229+
{
230+
ExitCode = 0,
231+
StandardOutput = "{}",
232+
StandardError = string.Empty
233+
});
234+
}
235+
236+
if (cmd == "az" && args != null && args.Contains("get-access-token", StringComparison.OrdinalIgnoreCase))
237+
{
238+
// Return token WITH newline characters - this simulates the real-world issue
239+
return Task.FromResult(new CommandResult
240+
{
241+
ExitCode = 0,
242+
StandardOutput = tokenWithNewlines,
243+
StandardError = string.Empty
244+
});
245+
}
246+
247+
return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty });
248+
});
249+
250+
var service = new GraphApiService(logger, executor, handler);
251+
252+
// Queue a successful response
253+
using var queuedResponse = new HttpResponseMessage(HttpStatusCode.OK)
254+
{
255+
Content = new StringContent("{\"value\":[]}")
256+
};
257+
handler.QueueResponse(queuedResponse);
258+
259+
// Act - This should NOT throw FormatException even with newlines in token
260+
var result = await service.GraphGetAsync("tenant-123", "/v1.0/me");
261+
262+
// Assert
263+
capturedRequest.Should().NotBeNull("HTTP request should have been sent");
264+
capturedRequest!.Headers.Authorization.Should().NotBeNull("Authorization header should be set");
265+
capturedRequest.Headers.Authorization!.Scheme.Should().Be("Bearer");
266+
267+
// The token in the header should NOT contain any newline characters
268+
var actualToken = capturedRequest.Headers.Authorization.Parameter;
269+
actualToken.Should().NotBeNull();
270+
actualToken.Should().NotContain("\r", "Token should not contain carriage return characters");
271+
actualToken.Should().NotContain("\n", "Token should not contain newline characters");
272+
actualToken.Should().NotStartWith(" ", "Token should not have leading whitespace");
273+
actualToken.Should().NotEndWith(" ", "Token should not have trailing whitespace");
274+
}
275+
276+
[Fact]
277+
public async Task GraphGetAsync_TokenFromTokenProvider_SanitizesNewlines()
278+
{
279+
// This test verifies that tokens from IMicrosoftGraphTokenProvider are also sanitized.
280+
// The token provider path uses a different code branch in EnsureGraphHeadersAsync.
281+
282+
// Arrange
283+
HttpRequestMessage? capturedRequest = null;
284+
var handler = new CapturingHttpMessageHandler((req) => capturedRequest = req);
285+
var logger = Substitute.For<ILogger<GraphApiService>>();
286+
var executor = Substitute.For<CommandExecutor>(Substitute.For<ILogger<CommandExecutor>>());
287+
var tokenProvider = Substitute.For<IMicrosoftGraphTokenProvider>();
288+
289+
// Mock token provider to return a token WITH embedded newlines
290+
tokenProvider.GetMgGraphAccessTokenAsync(
291+
Arg.Any<string>(),
292+
Arg.Any<IEnumerable<string>>(),
293+
Arg.Any<bool>(),
294+
Arg.Any<string?>(),
295+
Arg.Any<CancellationToken>())
296+
.Returns("token-from-provider\r\nwith-embedded-newlines\n");
297+
298+
var service = new GraphApiService(logger, executor, handler, tokenProvider);
299+
300+
// Queue a successful response
301+
using var queuedResponse = new HttpResponseMessage(HttpStatusCode.OK)
302+
{
303+
Content = new StringContent("{\"value\":[]}")
304+
};
305+
handler.QueueResponse(queuedResponse);
306+
307+
// Act - Call with scopes to trigger token provider path
308+
var result = await service.GraphGetAsync("tenant-123", "/v1.0/me", default, new[] { "User.Read" });
309+
310+
// Assert
311+
capturedRequest.Should().NotBeNull("HTTP request should have been sent");
312+
capturedRequest!.Headers.Authorization.Should().NotBeNull("Authorization header should be set");
313+
314+
var actualToken = capturedRequest.Headers.Authorization!.Parameter;
315+
actualToken.Should().NotBeNull();
316+
actualToken.Should().NotContain("\r", "Token should not contain carriage return characters");
317+
actualToken.Should().NotContain("\n", "Token should not contain newline characters");
318+
}
319+
320+
[Fact]
321+
public async Task CheckServicePrincipalCreationPrivilegesAsync_SanitizesTokenWithNewlines()
322+
{
323+
// This test verifies that CheckServicePrincipalCreationPrivilegesAsync also
324+
// sanitizes tokens with newlines. This method has its own token handling code
325+
// separate from EnsureGraphHeadersAsync.
326+
327+
// Arrange
328+
HttpRequestMessage? capturedRequest = null;
329+
var handler = new CapturingHttpMessageHandler((req) => capturedRequest = req);
330+
var logger = Substitute.For<ILogger<GraphApiService>>();
331+
var executor = Substitute.For<CommandExecutor>(Substitute.For<ILogger<CommandExecutor>>());
332+
333+
// Mock az CLI to return a token WITH newline characters
334+
executor.ExecuteAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
335+
.Returns(callInfo =>
336+
{
337+
var cmd = callInfo.ArgAt<string>(0);
338+
var args = callInfo.ArgAt<string>(1);
339+
340+
if (cmd == "az" && args != null && args.StartsWith("account show", StringComparison.OrdinalIgnoreCase))
341+
{
342+
return Task.FromResult(new CommandResult
343+
{
344+
ExitCode = 0,
345+
StandardOutput = "{}",
346+
StandardError = string.Empty
347+
});
348+
}
349+
350+
if (cmd == "az" && args != null && args.Contains("get-access-token", StringComparison.OrdinalIgnoreCase))
351+
{
352+
// Return token WITH embedded newlines
353+
return Task.FromResult(new CommandResult
354+
{
355+
ExitCode = 0,
356+
StandardOutput = "privileges-check-token\r\n\n",
357+
StandardError = string.Empty
358+
});
359+
}
360+
361+
return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty });
362+
});
363+
364+
var service = new GraphApiService(logger, executor, handler);
365+
366+
// Queue a successful response for the directory roles query
367+
using var queuedResponse = new HttpResponseMessage(HttpStatusCode.OK)
368+
{
369+
Content = new StringContent("{\"value\":[{\"displayName\":\"Application Administrator\"}]}")
370+
};
371+
handler.QueueResponse(queuedResponse);
372+
373+
// Act - This should NOT throw FormatException
374+
var (hasPrivileges, roles) = await service.CheckServicePrincipalCreationPrivilegesAsync("tenant-123");
375+
376+
// Assert
377+
capturedRequest.Should().NotBeNull("HTTP request should have been sent");
378+
capturedRequest!.Headers.Authorization.Should().NotBeNull("Authorization header should be set");
379+
380+
var actualToken = capturedRequest.Headers.Authorization!.Parameter;
381+
actualToken.Should().NotBeNull();
382+
actualToken.Should().NotContain("\r", "Token should not contain carriage return characters");
383+
actualToken.Should().NotContain("\n", "Token should not contain newline characters");
384+
actualToken.Should().Be("privileges-check-token", "Token should be sanitized to just the token value");
385+
386+
// Also verify the method returns correct results
387+
hasPrivileges.Should().BeTrue("User has Application Administrator role");
388+
roles.Should().Contain("Application Administrator");
389+
}
195390
}
196391

197392
// Simple test handler that returns queued responses sequentially

0 commit comments

Comments
 (0)