Skip to content

Commit 55815cb

Browse files
WilcoLouwerseclaude
andcommitted
fix(sync): quoted-string-aware header tokenizer + bump 0.2.24-woo-2 — WOO-552
Address Barry's review comment on the WOO-553 dev-branch PR (#1840 line 9111) by porting the same fix to the WOO stable hotfix line: `explode(';', $header)` also splits semicolons that live inside a quoted filename. `attachment; filename="rapport; versie 2.pdf"` corrupts to `rapport` instead of `rapport; versie 2.pdf`. The fix is a small quoted-string-aware tokenizer `splitHeaderParameters()` that tracks a `"`-open state and only splits `;` outside quotes, per RFC 6266 §4 grammar. Changes: - New private helper `splitHeaderParameters(string): array` on `SynchronizationService`. - `parseContentDispositionFilename()` now delegates the `;`-split to that helper instead of `explode(';', ...)` — behaviour on well-formed xxllnc headers is unchanged; corrupt scenarios that the naive split would drop are now preserved verbatim. - Rename local `$eq` → `$eqPos` in the parser (matches the post-review dev-branch style, avoids the phpmd ShortVariable rule if the hotfix line ever gets those checks). - 4 new unit tests locking behaviour: * `testFilenameWithSemicolonInsideQuotedValuePreservesFilename` — the general quoted-`;` case. * `testFilenameWithSemicolonAndSpaceInsideQuotedValuePreservesFilename` — Barry's exact example from PR #1840 (rapport; versie 2.pdf). * `testFilenameWithPathTraversalPayloadIsReturnedVerbatim` — contract: sanitization is downstream's responsibility. * `testFilenameWithWhitespaceAroundEqualsIsAccepted` — locks `filename = "…"` handling. Total: 14 tests (was 10), all green locally. `appinfo/info.xml` version bumped `0.2.24-woo-1` → `0.2.24-woo-2` so the next push cuts a fresh prerelease `v0.2.24-woo-2` that supersedes the currently-installed `v0.2.24-woo-1` on openwoo.commonground.nu. Deploy via `app_versions` app pin (same mechanic Wilco used on r17). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f4208e4 commit 55815cb

3 files changed

Lines changed: 94 additions & 11 deletions

File tree

appinfo/info.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ The OpenConnector Nextcloud app provides a ESB-framework to work together in an
1515
- 🆓 Map and translate API calls
1616
1717
]]></description>
18-
<version>0.2.24-woo-1</version>
18+
<version>0.2.24-woo-2</version>
1919
<licence>agpl</licence>
2020
<category>integration</category>
2121
<author mail="info@conduction.nl" homepage="https://www.conduction.nl/">Conduction</author>

lib/Service/SynchronizationService.php

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3086,12 +3086,15 @@ private function getFilenameFromHeaders(array $response, CallLog $result): ?stri
30863086
*/
30873087
private function parseContentDispositionFilename(string $headerValue): ?string
30883088
{
3089-
// Split the header into parameter segments on `;`. The first segment
3090-
// is the disposition-type (attachment / inline), the rest are
3091-
// parameters. Splitting on `;` (instead of `=`) is what the naive
3092-
// pre-WOO-552 code got wrong: any `=` inside a value (bv. the
3093-
// charset''value shape of filename*) fooled the extractor.
3094-
$segments = array_map('trim', explode(';', $headerValue));
3089+
// Split the header into parameter segments on `;` while respecting
3090+
// RFC 6266 §4 quoted-string grammar. Splitting on `;` (instead of
3091+
// `=`) is what the naive pre-WOO-552 code got wrong: any `=` inside
3092+
// a value (bv. the charset''value shape of filename*) fooled the
3093+
// extractor. And a NAIVE `explode(';', $header)` in turn corrupts
3094+
// filenames that legitimately contain a `;` inside quotes
3095+
// (bv. `filename="rapport; versie 2.pdf"`) — that's why we use
3096+
// the quoted-string-aware splitHeaderParameters() tokenizer.
3097+
$segments = $this->splitHeaderParameters($headerValue);
30953098

30963099
$filenameStar = null;
30973100
$filenamePlain = null;
@@ -3100,12 +3103,12 @@ private function parseContentDispositionFilename(string $headerValue): ?string
31003103
// Split into name/value on the FIRST `=` only — the value side
31013104
// may legitimately contain further `=` characters (RFC 5987
31023105
// extended values, base64-ish payloads).
3103-
$eq = strpos($segment, '=');
3104-
if ($eq === false) {
3106+
$eqPos = strpos($segment, '=');
3107+
if ($eqPos === false) {
31053108
continue;
31063109
}
3107-
$name = strtolower(trim(substr($segment, 0, $eq)));
3108-
$value = trim(substr($segment, $eq + 1));
3110+
$name = strtolower(trim(substr($segment, 0, $eqPos)));
3111+
$value = trim(substr($segment, $eqPos + 1));
31093112

31103113
if ($name === 'filename*') {
31113114
$filenameStar = $this->decodeRfc5987ExtendedValue($value);
@@ -3120,6 +3123,47 @@ private function parseContentDispositionFilename(string $headerValue): ?string
31203123
return $filenameStar ?? $filenamePlain;
31213124
}
31223125

3126+
/**
3127+
* Split a Content-Disposition header value into parameter segments,
3128+
* respecting RFC 6266 §4 quoted-string grammar.
3129+
*
3130+
* A naive `explode(';', $header)` also splits semicolons that are
3131+
* inside a quoted filename. For example,
3132+
* `attachment; filename="rapport; versie 2.pdf"` would corrupt to
3133+
* `rapport` instead of `rapport; versie 2.pdf`. This tokenizer
3134+
* tracks quote-open state and only splits on `;` outside quotes.
3135+
* The returned segments are trimmed.
3136+
*
3137+
* @param string $headerValue The raw Content-Disposition header value.
3138+
* @return array<int, string> Trimmed parameter segments, starting with
3139+
* the disposition-type (attachment/inline).
3140+
*/
3141+
private function splitHeaderParameters(string $headerValue): array
3142+
{
3143+
$segments = [];
3144+
$current = '';
3145+
$inQuotes = false;
3146+
$length = strlen($headerValue);
3147+
for ($i = 0; $i < $length; $i++) {
3148+
$char = $headerValue[$i];
3149+
if ($char === '"') {
3150+
$inQuotes = ($inQuotes === false);
3151+
$current .= $char;
3152+
continue;
3153+
}
3154+
if ($char === ';' && $inQuotes === false) {
3155+
$segments[] = trim($current);
3156+
$current = '';
3157+
continue;
3158+
}
3159+
$current .= $char;
3160+
}
3161+
if ($current !== '') {
3162+
$segments[] = trim($current);
3163+
}
3164+
return $segments;
3165+
}
3166+
31233167
/**
31243168
* Decode an RFC 5987 extended parameter value of shape `charset''pct-encoded`.
31253169
*

tests/Unit/Service/SynchronizationServiceContentDispositionTest.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,43 @@ public function testHeaderWithoutAnyFilenameReturnsNull(): void
129129
$header = 'inline';
130130
$this->assertNull($this->invokeParser($header));
131131
}
132+
133+
public function testFilenameWithSemicolonInsideQuotedValuePreservesFilename(): void
134+
{
135+
// RFC 6266 §4 quoted-string grammar: a `;` between quotes is part
136+
// of the value, not a parameter separator. A naive
137+
// `explode(';', $header)` corrupts this to just `foo`; the
138+
// quoted-string-aware tokenizer preserves the full filename.
139+
$header = 'attachment; filename="foo;bar.pdf"';
140+
$this->assertSame('foo;bar.pdf', $this->invokeParser($header));
141+
}
142+
143+
public function testFilenameWithSemicolonAndSpaceInsideQuotedValuePreservesFilename(): void
144+
{
145+
// Barry's concrete example on PR #1840. Locks the exact string he
146+
// raised so the guarantee is explicit in the test suite, not only
147+
// implied by the more abstract `foo;bar.pdf` case above.
148+
$header = 'attachment; filename="rapport; versie 2.pdf"';
149+
$this->assertSame('rapport; versie 2.pdf', $this->invokeParser($header));
150+
}
151+
152+
public function testFilenameWithPathTraversalPayloadIsReturnedVerbatim(): void
153+
{
154+
// Contract: the parser extracts the filename as declared upstream;
155+
// path-separator / `..` sanitization is the responsibility of the
156+
// downstream FileService::saveFile() writer. Locking this contract
157+
// guards against a future refactor silently sanitizing here (which
158+
// would hide malicious input from the writer's audit surface).
159+
$header = 'attachment; filename="../../etc/passwd"';
160+
$this->assertSame('../../etc/passwd', $this->invokeParser($header));
161+
}
162+
163+
public function testFilenameWithWhitespaceAroundEqualsIsAccepted(): void
164+
{
165+
// Well-behaved servers do not emit whitespace around `=`, but the
166+
// tokenizer's `trim()` handles it gracefully. Locks the behaviour
167+
// so a future refactor does not silently regress it.
168+
$header = 'attachment; filename = "bestand.pdf"';
169+
$this->assertSame('bestand.pdf', $this->invokeParser($header));
170+
}
132171
}

0 commit comments

Comments
 (0)