Skip to content

Commit abf5dc4

Browse files
committed
Normalize URL paths in redirect handling: resolve . and .. segments in relative and absolute paths
1 parent a8f7ec0 commit abf5dc4

File tree

2 files changed

+143
-6
lines changed

2 files changed

+143
-6
lines changed

src/FeedIo/Adapter/Http/Client.php

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,22 +171,73 @@ protected function resolveRedirectUrl(string $currentUrl, string $location): str
171171

172172
$scheme = $parts['scheme'] ?? 'http';
173173
$host = $parts['host'] ?? '';
174+
$port = isset($parts['port']) ? ':' . $parts['port'] : '';
174175

175176
// Handle absolute path (starts with /)
176177
if (str_starts_with($location, '/')) {
177-
$port = isset($parts['port']) ? ':' . $parts['port'] : '';
178-
return "{$scheme}://{$host}{$port}{$location}";
178+
$normalizedPath = $this->normalizePath($location);
179+
return "{$scheme}://{$host}{$port}{$normalizedPath}";
179180
}
180181

181182
// Handle relative path
182-
$path = $parts['path'] ?? '/';
183-
$basePath = dirname($path);
183+
$currentPath = $parts['path'] ?? '/';
184+
$basePath = dirname($currentPath);
184185
if ($basePath === '.') {
185186
$basePath = '/';
186187
}
187188

188-
$port = isset($parts['port']) ? ':' . $parts['port'] : '';
189+
// Combine base path with relative location
189190
$separator = str_ends_with($basePath, '/') ? '' : '/';
190-
return "{$scheme}://{$host}{$port}{$basePath}{$separator}{$location}";
191+
$combinedPath = "{$basePath}{$separator}{$location}";
192+
193+
// Normalize the path to resolve . and .. segments
194+
$normalizedPath = $this->normalizePath($combinedPath);
195+
196+
return "{$scheme}://{$host}{$port}{$normalizedPath}";
197+
}
198+
199+
/**
200+
* Normalize a URL path by resolving . and .. segments
201+
*
202+
* @param string $path
203+
* @return string
204+
*/
205+
protected function normalizePath(string $path): string
206+
{
207+
// Split path into segments
208+
$segments = explode('/', $path);
209+
$normalized = [];
210+
211+
foreach ($segments as $segment) {
212+
if ($segment === '' || $segment === '.') {
213+
// Skip empty segments and current directory references
214+
if ($segment === '' && empty($normalized)) {
215+
// Keep leading slash
216+
$normalized[] = '';
217+
}
218+
continue;
219+
} elseif ($segment === '..') {
220+
// Go up one directory
221+
if (!empty($normalized) && end($normalized) !== '' && end($normalized) !== '..') {
222+
array_pop($normalized);
223+
} elseif (empty($normalized) || end($normalized) === '..') {
224+
// Can't go above root or if we're already tracking ..
225+
$normalized[] = '..';
226+
}
227+
} else {
228+
$normalized[] = $segment;
229+
}
230+
}
231+
232+
// Reconstruct path
233+
$result = implode('/', $normalized);
234+
235+
// Ensure absolute paths start with /
236+
if (str_starts_with($path, '/') && !str_starts_with($result, '/')) {
237+
$result = '/' . $result;
238+
}
239+
240+
return $result ?: '/';
191241
}
192242
}
243+

tests/FeedIo/Adapter/Http/ClientTest.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,4 +305,90 @@ public function testAllowsHttpAndHttpsRedirects(): void
305305

306306
$this->assertEquals(200, $response->getStatusCode());
307307
}
308+
309+
public function testNormalizesRelativePathWithDotDotSegments(): void
310+
{
311+
// Current URL: https://example.com/path/to/feed.xml
312+
// Redirect to: ../newpath/feed.xml
313+
// Should resolve to: https://example.com/path/newpath/feed.xml
314+
315+
$redirectResponse = new PsrResponse(301, ['Location' => '../newpath/feed.xml']);
316+
$finalResponse = new PsrResponse(200, [], 'content');
317+
318+
$requestCount = 0;
319+
$this->psrClient
320+
->expects($this->exactly(2))
321+
->method('sendRequest')
322+
->willReturnCallback(function (RequestInterface $request) use ($redirectResponse, $finalResponse, &$requestCount) {
323+
$requestCount++;
324+
325+
if ($requestCount === 1) {
326+
$this->assertEquals('https://example.com/path/to/feed.xml', (string) $request->getUri());
327+
return $redirectResponse;
328+
}
329+
330+
// Verify the path was properly normalized
331+
$this->assertEquals('https://example.com/path/newpath/feed.xml', (string) $request->getUri());
332+
return $finalResponse;
333+
});
334+
335+
$response = $this->client->getResponse('https://example.com/path/to/feed.xml');
336+
$this->assertEquals(200, $response->getStatusCode());
337+
}
338+
339+
public function testNormalizesAbsolutePathWithDotDotSegments(): void
340+
{
341+
// Redirect to: /path/../other/feed.xml
342+
// Should resolve to: /other/feed.xml
343+
344+
$redirectResponse = new PsrResponse(301, ['Location' => '/path/../other/feed.xml']);
345+
$finalResponse = new PsrResponse(200, [], 'content');
346+
347+
$requestCount = 0;
348+
$this->psrClient
349+
->expects($this->exactly(2))
350+
->method('sendRequest')
351+
->willReturnCallback(function (RequestInterface $request) use ($redirectResponse, $finalResponse, &$requestCount) {
352+
$requestCount++;
353+
354+
if ($requestCount === 1) {
355+
return $redirectResponse;
356+
}
357+
358+
// Verify the path was properly normalized
359+
$this->assertEquals('https://example.com/other/feed.xml', (string) $request->getUri());
360+
return $finalResponse;
361+
});
362+
363+
$response = $this->client->getResponse('https://example.com/some/path.xml');
364+
$this->assertEquals(200, $response->getStatusCode());
365+
}
366+
367+
public function testNormalizesPathWithDotSegments(): void
368+
{
369+
// Redirect to: /path/./to/./feed.xml
370+
// Should resolve to: /path/to/feed.xml
371+
372+
$redirectResponse = new PsrResponse(301, ['Location' => '/path/./to/./feed.xml']);
373+
$finalResponse = new PsrResponse(200, [], 'content');
374+
375+
$requestCount = 0;
376+
$this->psrClient
377+
->expects($this->exactly(2))
378+
->method('sendRequest')
379+
->willReturnCallback(function (RequestInterface $request) use ($redirectResponse, $finalResponse, &$requestCount) {
380+
$requestCount++;
381+
382+
if ($requestCount === 1) {
383+
return $redirectResponse;
384+
}
385+
386+
// Verify the path was properly normalized
387+
$this->assertEquals('https://example.com/path/to/feed.xml', (string) $request->getUri());
388+
return $finalResponse;
389+
});
390+
391+
$response = $this->client->getResponse('https://example.com/old.xml');
392+
$this->assertEquals(200, $response->getStatusCode());
393+
}
308394
}

0 commit comments

Comments
 (0)