-
Notifications
You must be signed in to change notification settings - Fork 1
test(web): pin both edges of the SSRF guard's IPv6 bit-mask ranges #1512
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
131 changes: 131 additions & 0 deletions
131
apps/web/src/lib/__tests__/ssrf-guard-range-boundaries.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| import { describe, it, expect, afterEach, vi } from 'vitest'; | ||
|
|
||
| vi.mock('node:dns/promises', () => ({ | ||
| lookup: vi.fn(), | ||
| })); | ||
|
|
||
| import * as dns from 'node:dns/promises'; | ||
| import { assertPublicHttpUrl } from '@/lib/ssrf-guard'; | ||
|
|
||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| vi.mocked(dns.lookup).mockReset(); | ||
| }); | ||
|
|
||
| /** | ||
| * Boundary coverage for the IPv6 range checks in `ipIsPrivate`. | ||
| * | ||
| * `ssrf-guard.test.ts` establishes that each blocked range *is* blocked — it | ||
| * walks NAT64, 6to4, IPv4-translated, site-local and discard with an address | ||
| * squarely inside each. That is the "too narrow" direction: a check that missed | ||
| * would let an address through. | ||
| * | ||
| * Nothing yet holds the other direction. `fec0::/10` and `100::/64` are the two | ||
| * checks written as bit masks rather than exact matches, and a mask that is too | ||
| * *wide* fails silently in the opposite way — it refuses public space, and the | ||
| * only symptom is a legitimate fetch being rejected with the same uniform | ||
| * message every other rejection uses. Neither edge of either mask is pinned | ||
| * today, so widening one would break no test. | ||
| * | ||
| * Each range therefore gets both edges plus a just-outside neighbour. | ||
| */ | ||
| describe('assertPublicHttpUrl IPv6 range boundaries', () => { | ||
| const rejectionOf = async (url: string): Promise<Error> => { | ||
| vi.spyOn(console, 'error').mockImplementation(() => {}); | ||
| try { | ||
| await assertPublicHttpUrl(url); | ||
| } catch (err) { | ||
| // `catch` binds as `unknown`; narrow rather than assert, so a non-Error | ||
| // throw surfaces as itself instead of being mistyped as an Error and | ||
| // failing later on a missing `.message`. | ||
| if (err instanceof Error) return err; | ||
| throw err; | ||
| } | ||
| throw new Error(`${url} was expected to be rejected, but was allowed`); | ||
| }; | ||
|
|
||
| describe('fec0::/10 site-local', () => { | ||
| it.each([ | ||
| ['fec0::', 'first address in the range'], | ||
| ['feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', 'last address in the range'], | ||
| ])('rejects [%s] (%s)', async (address) => { | ||
| const err = await rejectionOf(`http://[${address}]/`); | ||
|
|
||
| expect(err.message).toBe('Blocked private IP literal'); | ||
| }); | ||
|
|
||
| /** | ||
| * `fe00::1` sits below `fec0::/10` and is not caught by `fe80::/10` either, | ||
| * so it lands in the gap between the two masks. If the site-local check were | ||
| * widened to `fe00::/8` — an easy slip, since both start `fe` — this is the | ||
| * address that would start being refused. | ||
| */ | ||
| it('still allows [fe00::1], which is below the range', async () => { | ||
| const url = await assertPublicHttpUrl('http://[fe00::1]/'); | ||
|
|
||
| expect(url.hostname).toBe('[fe00::1]'); | ||
| expect(dns.lookup).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('100::/64 discard', () => { | ||
| it.each([ | ||
| ['100::', 'first address in the range'], | ||
| ['100:0:0:0:ffff:ffff:ffff:ffff', 'last address in the range'], | ||
| ])('rejects [%s] (%s)', async (address) => { | ||
| const err = await rejectionOf(`http://[${address}]/`); | ||
|
|
||
| expect(err.message).toBe('Blocked private IP literal'); | ||
| }); | ||
|
|
||
| /** | ||
| * The discard prefix is a /64, not a /16 or /32. Both of these share the | ||
| * leading `100` hextet and would be refused if the check tested only `h[0]`. | ||
| */ | ||
| it.each([ | ||
| ['100:0:0:1::1', 'inside 100::/16 but outside the /64'], | ||
| ['101::1', 'adjacent prefix'], | ||
| ])('still allows [%s] (%s)', async (address) => { | ||
| const url = await assertPublicHttpUrl(`http://[${address}]/`); | ||
|
|
||
| expect(url.hostname).toBe(`[${address}]`); | ||
| }); | ||
| }); | ||
|
|
||
| /** | ||
| * `ipv6ToHextets` folds a trailing dotted quad into two hextets before any | ||
| * range check runs, so the dotted spelling of a NAT64 address has to reach the | ||
| * same verdict as the hex one. `ssrf-guard.test.ts` covers | ||
| * `64:ff9b::a9fe:a9fe`; this is the same destination written the other way. | ||
| */ | ||
| it('rejects the dotted-quad spelling of a NAT64 address', async () => { | ||
| const err = await rejectionOf('http://[64:ff9b::169.254.169.254]/'); | ||
|
|
||
| expect(err.message).toBe('Blocked private IP literal'); | ||
| }); | ||
|
|
||
| /** | ||
| * The same boundaries via DNS answers rather than URL literals. The two paths | ||
| * reach `ipIsPrivate` through different branches of `assertPublicHttpUrl` and | ||
| * report different messages, so a regression could land on one and not the | ||
| * other. | ||
| */ | ||
| describe('applied to resolved addresses', () => { | ||
| it('rejects a site-local answer at the top of the range', async () => { | ||
| vi.mocked(dns.lookup).mockResolvedValue([{ address: 'feff::1', family: 6 }] as never); | ||
| vi.spyOn(console, 'error').mockImplementation(() => {}); | ||
|
|
||
| await expect(assertPublicHttpUrl('https://edge.example/a.mp3')).rejects.toThrow( | ||
| 'Host does not resolve to a public address', | ||
| ); | ||
| }); | ||
|
|
||
| it('still allows an answer just outside the discard prefix', async () => { | ||
| vi.mocked(dns.lookup).mockResolvedValue([{ address: '101::1', family: 6 }] as never); | ||
|
|
||
| const url = await assertPublicHttpUrl('https://edgeok.example/a.mp3'); | ||
|
|
||
| expect(url.hostname).toBe('edgeok.example'); | ||
| }); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.