|
23 | 23 | * `?password=` query parameter still is, and a wrong password is refused |
24 | 24 | * through either form; |
25 | 25 | * - no log line carries the presented password; |
| 26 | + * - [#22049] `X-Share-Password-Encoding: utf-8` declares the header |
| 27 | + * percent-encoded UTF-8, so a CJK and an emoji password resolve through it |
| 28 | + * on both public routes; without it a Latin-1 password and a raw one |
| 29 | + * containing `%` resolve unchanged; a declared encoding that does not hold |
| 30 | + * is refused `400 VALIDATION_FAILED` before the token is looked up, never |
| 31 | + * compared raw; |
26 | 32 | * - both public routes answer `Cache-Control: no-store` and |
27 | | - * `Vary: X-Share-Password` on every outcome, and the authenticated routes |
28 | | - * do not; |
| 33 | + * `Vary: X-Share-Password, X-Share-Password-Encoding` on every outcome, and |
| 34 | + * the authenticated routes do not; |
29 | 35 | * - the pure-JS scrypt the WebContainer path uses and `node:crypto`'s produce |
30 | 36 | * interchangeable hashes. |
31 | 37 | */ |
@@ -478,10 +484,127 @@ describe('[#21839] how the password travels in', () => { |
478 | 484 | }); |
479 | 485 | }); |
480 | 486 |
|
| 487 | +describe('[#22049] the password header declares its encoding', () => { |
| 488 | + const CJK = '分享密码二二零四九'; |
| 489 | + const EMOJI = 'open 🔐🦊 sesame'; |
| 490 | + const LATIN1 = 'Déjà vu ½ ÿ 22049'; |
| 491 | + const RAW_PERCENT = 'grade 100% %zz 22049'; |
| 492 | + const RAW_PERCENT_ESCAPE = '50%25 off 22049'; |
| 493 | + const UTF8 = { 'x-share-password-encoding': 'utf-8' }; |
| 494 | + |
| 495 | + async function protectedConversation(password: string) { |
| 496 | + const booted = await boot(); |
| 497 | + const link = await booted.service.createLink( |
| 498 | + { object: 'ai_conversations', recordId: 'conv_1', password }, |
| 499 | + CREATOR, |
| 500 | + ); |
| 501 | + return { ...booted, link }; |
| 502 | + } |
| 503 | + |
| 504 | + function expectServed(route: 'resolve' | 'messages', res: { status: number; body: any }, label: string) { |
| 505 | + expect(res.status, label).toBe(200); |
| 506 | + expect(res.body?.success, label).toBe(true); |
| 507 | + if (route === 'messages') expect(res.body?.data?.map((m: Row) => m.id), label).toEqual(['msg_1']); |
| 508 | + else expect(res.body?.data?.record?.id, label).toBe('conv_1'); |
| 509 | + } |
| 510 | + |
| 511 | + it.each([ |
| 512 | + ['resolve', 'CJK', CJK], |
| 513 | + ['resolve', 'emoji', EMOJI], |
| 514 | + ['messages', 'CJK', CJK], |
| 515 | + ['messages', 'emoji', EMOJI], |
| 516 | + ] as const)('/%s serves a %s password sent percent-encoded under utf-8', async (route, label, password) => { |
| 517 | + const { http, link } = await protectedConversation(password); |
| 518 | + const res = await drive(http, `GET ${B}/:token/${route}`, { |
| 519 | + params: { token: link.token }, |
| 520 | + headers: { 'x-share-password': encodeURIComponent(password), ...UTF8 }, |
| 521 | + }); |
| 522 | + expectServed(route, res, label); |
| 523 | + }); |
| 524 | + |
| 525 | + it.each([ |
| 526 | + ['resolve', 401, 'WRONG_PASSWORD'], |
| 527 | + ['messages', 404, 'NOT_FOUND'], |
| 528 | + ] as const)('/%s does not guess: the same encoded value with no encoding header is compared raw', async (route, status, code) => { |
| 529 | + const { http, link } = await protectedConversation(CJK); |
| 530 | + const res = await drive(http, `GET ${B}/:token/${route}`, { |
| 531 | + params: { token: link.token }, |
| 532 | + headers: { 'x-share-password': encodeURIComponent(CJK) }, |
| 533 | + }); |
| 534 | + expect(res.status).toBe(status); |
| 535 | + expect(res.body?.error?.code).toBe(code); |
| 536 | + }); |
| 537 | + |
| 538 | + it.each([ |
| 539 | + ['resolve', 'Latin-1', LATIN1], |
| 540 | + ['resolve', 'raw with a stray %', RAW_PERCENT], |
| 541 | + ['resolve', 'raw with a %-escape', RAW_PERCENT_ESCAPE], |
| 542 | + ['messages', 'Latin-1', LATIN1], |
| 543 | + ['messages', 'raw with a stray %', RAW_PERCENT], |
| 544 | + ['messages', 'raw with a %-escape', RAW_PERCENT_ESCAPE], |
| 545 | + ] as const)('/%s still serves a %s password sent raw, with no encoding header', async (route, label, password) => { |
| 546 | + const { http, link } = await protectedConversation(password); |
| 547 | + const res = await drive(http, `GET ${B}/:token/${route}`, { |
| 548 | + params: { token: link.token }, |
| 549 | + headers: { 'x-share-password': password }, |
| 550 | + }); |
| 551 | + expectServed(route, res, label); |
| 552 | + }); |
| 553 | + |
| 554 | + it.each([ |
| 555 | + ['resolve', 'a truncated UTF-8 sequence', { 'x-share-password': '%E5%88', ...UTF8 }], |
| 556 | + ['resolve', 'an octet that is not UTF-8', { 'x-share-password': '%FF', ...UTF8 }], |
| 557 | + ['resolve', 'an encoding the server does not read', { 'x-share-password': encodeURIComponent(CJK), 'x-share-password-encoding': 'latin1' }], |
| 558 | + ['messages', 'a truncated UTF-8 sequence', { 'x-share-password': '%E5%88', ...UTF8 }], |
| 559 | + ['messages', 'an octet that is not UTF-8', { 'x-share-password': '%FF', ...UTF8 }], |
| 560 | + ['messages', 'an encoding the server does not read', { 'x-share-password': encodeURIComponent(CJK), 'x-share-password-encoding': 'latin1' }], |
| 561 | + ] as const)('/%s refuses %s with 400 VALIDATION_FAILED, before the token is looked up', async (route, _label, headers) => { |
| 562 | + const { http, link, service } = await protectedConversation(CJK); |
| 563 | + const resolveToken = vi.spyOn(service, 'resolveToken'); |
| 564 | + const res = await drive(http, `GET ${B}/:token/${route}`, { params: { token: link.token }, headers }); |
| 565 | + expect(res.status).toBe(400); |
| 566 | + expect(res.body?.success).toBe(false); |
| 567 | + expect(res.body?.error?.code).toBe('VALIDATION_FAILED'); |
| 568 | + expect(res.body?.error?.message).toContain('X-Share-Password'); |
| 569 | + expect(JSON.stringify(res.body)).not.toContain(headers['x-share-password']); |
| 570 | + expect(res.headers['Cache-Control']).toBe('no-store'); |
| 571 | + expect(resolveToken).not.toHaveBeenCalled(); |
| 572 | + }); |
| 573 | + |
| 574 | + it.each(['resolve', 'messages'] as const)( |
| 575 | + '/%s never falls back to a raw compare: an undecodable value that IS the raw password is still refused', |
| 576 | + async (route) => { |
| 577 | + const rawPassword = '%E5%88 22049'; |
| 578 | + const { http, link } = await protectedConversation(rawPassword); |
| 579 | + const raw = await drive(http, `GET ${B}/:token/${route}`, { |
| 580 | + params: { token: link.token }, |
| 581 | + headers: { 'x-share-password': rawPassword }, |
| 582 | + }); |
| 583 | + expectServed(route, raw, 'the raw form, undeclared'); |
| 584 | + const declared = await drive(http, `GET ${B}/:token/${route}`, { |
| 585 | + params: { token: link.token }, |
| 586 | + headers: { 'x-share-password': rawPassword, ...UTF8 }, |
| 587 | + }); |
| 588 | + expect(declared.status).toBe(400); |
| 589 | + expect(declared.body?.error?.code).toBe('VALIDATION_FAILED'); |
| 590 | + }, |
| 591 | + ); |
| 592 | + |
| 593 | + it.each(['resolve', 'messages'] as const)('/%s: the ?password= form still wins, and the header pair is then not read', async (route) => { |
| 594 | + const { http, link } = await protectedConversation(CJK); |
| 595 | + const res = await drive(http, `GET ${B}/:token/${route}`, { |
| 596 | + params: { token: link.token }, |
| 597 | + query: { password: CJK }, |
| 598 | + headers: { 'x-share-password': '%FF', 'x-share-password-encoding': 'latin1' }, |
| 599 | + }); |
| 600 | + expectServed(route, res, 'query wins'); |
| 601 | + }); |
| 602 | +}); |
| 603 | + |
481 | 604 | describe('[#21839] the public routes are never cached', () => { |
482 | 605 | function expectNoStore(res: { headers: Record<string, string | string[]> }, label: string) { |
483 | 606 | expect(res.headers['Cache-Control'], label).toBe('no-store'); |
484 | | - expect(res.headers.Vary, label).toBe('X-Share-Password'); |
| 607 | + expect(res.headers.Vary, label).toBe('X-Share-Password, X-Share-Password-Encoding'); |
485 | 608 | } |
486 | 609 |
|
487 | 610 | it.each(['resolve', 'messages'] as const)('/%s sends no-store + Vary on success and on every refusal', async (route) => { |
|
0 commit comments