1
+package cli
2
+
3
+import (
4
+ "context"
5
+ "fmt"
6
+ "io"
7
+ "log"
8
+ "net/http"
9
+ "net/url"
10
+ "os"
11
+ "path/filepath"
12
+ "strings"
13
+ "testing"
14
+
15
+ "github.com/ipfs/kubo/test/cli/harness"
16
+ "github.com/libp2p/go-libp2p"
17
+ "github.com/libp2p/go-libp2p/core/peer"
18
+ libp2phttp "github.com/libp2p/go-libp2p/p2p/http"
19
+ "github.com/stretchr/testify/assert"
20
+ "github.com/stretchr/testify/require"
21
+)
22
+
23
+func TestContentBlocking(t *testing.T) {
24
+ // NOTE: we can't run this with t.Parallel() because we set IPFS_NS_MAP
25
+ // and running in parallel could impact other tests
26
+
27
+ const blockedMsg = "blocked and cannot be provided"
28
+ const statusExpl = "specific HTTP error code is expected"
29
+ const bodyExpl = "Error message informing about content block is expected"
30
+
31
+ h := harness.NewT(t)
32
+
33
+ // Init IPFS_PATH
34
+ node := h.NewNode().Init("--empty-repo", "--profile=test")
35
+
36
+ // Create CIDs we use in test
37
+ h.WriteFile("blocked-dir/subdir/indirectly-blocked-file.txt", "indirectly blocked file content")
38
+ parentDirCID := node.IPFS("add", "--raw-leaves", "-Q", "-r", filepath.Join(h.Dir, "blocked-dir")).Stdout.Trimmed()
39
+
40
+ h.WriteFile("directly-blocked-file.txt", "directly blocked file content")
41
+ blockedCID := node.IPFS("add", "--raw-leaves", "-Q", filepath.Join(h.Dir, "directly-blocked-file.txt")).Stdout.Trimmed()
42
+
43
+ h.WriteFile("not-blocked-file.txt", "not blocked file content")
44
+ allowedCID := node.IPFS("add", "--raw-leaves", "-Q", filepath.Join(h.Dir, "not-blocked-file.txt")).Stdout.Trimmed()
45
+
46
+ // Create denylist at $IPFS_PATH/denylists/test.deny
47
+ denylistTmp := h.WriteToTemp("name: test list\n---\n" +
48
+ "//QmX9dhRcQcKUw3Ws8485T5a9dtjrSCQaUAHnG4iK9i4ceM\n" + // Double hash (sha256) CID block: base58btc(sha256-multihash(QmVTF1yEejXd9iMgoRTFDxBv7HAz9kuZcQNBzHrceuK9HR))
49
+ "//gW813G35CnLsy7gRYYHuf63hrz71U1xoLFDVeV7actx6oX\n" + // Double hash (blake3) Path block under blake3 root CID: base58btc(blake3-multihash(gW7Nhu4HrfDtphEivm3Z9NNE7gpdh5Tga8g6JNZc1S8E47/path))
50
+ "//8526ba05eec55e28f8db5974cc891d0d92c8af69d386fc6464f1e9f372caf549\n" + // Legacy CID double-hash block: sha256(bafkqahtcnrxwg23fmqqgi33vmjwgk2dbonuca3dfm5qwg6jamnuwicq/)
51
+ "//e5b7d2ce2594e2e09901596d8e1f29fa249b74c8c9e32ea01eda5111e4d33f07\n" + // Legacy Path double-hash block: sha256(bafyaagyscufaqalqaacauaqiaejao43vmjygc5didacauaqiae/subpath)
52
+ "/ipfs/" + blockedCID + "\n" + // block specific CID
53
+ "/ipfs/" + parentDirCID + "/subdir*\n" + // block only specific subpath
54
+ "/ipns/blocked-cid.example.com\n" +
55
+ "/ipns/blocked-dnslink.example.com\n")
56
+
57
+ if err := os.MkdirAll(filepath.Join(node.Dir, "denylists"), 0o777); err != nil {
58
+ log.Panicf("failed to create denylists dir: %s", err.Error())
59
+ }
60
+ if err := os.Rename(denylistTmp, filepath.Join(node.Dir, "denylists", "test.deny")); err != nil {
61
+ log.Panicf("failed to create test denylist: %s", err.Error())
62
+ }
63
+
64
+ // Add two entries to namesys resolution cache
65
+ // /ipns/blocked-cid.example.com point at a blocked CID (to confirm blocking impacts /ipns resolution)
66
+ // /ipns/blocked-dnslink.example.com with safe CID (to test blocking of /ipns/ paths)
67
+ os.Setenv("IPFS_NS_MAP", "blocked-cid.example.com:/ipfs/"+blockedCID+",blocked-dnslink.example.com/ipns/QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn")
68
+ defer os.Unsetenv("IPFS_NS_MAP")
69
+
70
+ // Enable GatewayOverLibp2p as we want to test denylist there too
71
+ node.IPFS("config", "--json", "Experimental.GatewayOverLibp2p", "true")
72
+
73
+ // Start daemon, it should pick up denylist from $IPFS_PATH/denylists/test.deny
74
+ node.StartDaemon() // we need online mode for GatewayOverLibp2p tests
75
+ client := node.GatewayClient()
76
+
77
+ // First, confirm gateway works
78
+ t.Run("Gateway Allows CID that is not blocked", func(t *testing.T) {
79
+ t.Parallel()
80
+ resp := client.Get("/ipfs/" + allowedCID)
81
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
82
+ assert.Equal(t, "not blocked file content", resp.Body)
83
+ })
84
+
85
+ // Then, does the most basic blocking case work?
86
+ t.Run("Gateway Denies directly blocked CID", func(t *testing.T) {
87
+ t.Parallel()
88
+ resp := client.Get("/ipfs/" + blockedCID)
89
+ assert.Equal(t, http.StatusGone, resp.StatusCode, statusExpl)
90
+ assert.NotEqual(t, "directly blocked file content", resp.Body)
91
+ assert.Contains(t, resp.Body, blockedMsg, bodyExpl)
92
+ })
93
+
94
+ // Confirm parent of blocked subpath is not blocked
95
+ t.Run("Gateway Allows parent Path that is not blocked", func(t *testing.T) {
96
+ t.Parallel()
97
+ resp := client.Get("/ipfs/" + parentDirCID)
98
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
99
+ })
100
+
101
+ // Ok, now the full list of test cases we want to cover in both CLI and Gateway
102
+ testCases := []struct {
103
+ name string
104
+ path string
105
+ }{
106
+ {
107
+ name: "directly blocked CID",
108
+ path: "/ipfs/" + blockedCID,
109
+ },
110
+ {
111
+ name: "indirectly blocked file (on a blocked subpath)",
112
+ path: "/ipfs/" + parentDirCID + "/subdir/indirectly-blocked-file.txt",
113
+ },
114
+ {
115
+ name: "/ipns path that resolves to a blocked CID",
116
+ path: "/ipns/blocked-cid.example.com",
117
+ },
118
+ {
119
+ name: "/ipns Path that is blocked by DNSLink name",
120
+ path: "/ipns/blocked-dnslink.example.com",
121
+ },
122
+ {
123
+ name: "double-hash CID block (sha256-multihash)",
124
+ path: "/ipfs/QmVTF1yEejXd9iMgoRTFDxBv7HAz9kuZcQNBzHrceuK9HR",
125
+ },
126
+ {
127
+ name: "double-hash Path block (blake3-multihash)",
128
+ path: "/ipfs/bafyb4ieqht3b2rssdmc7sjv2cy2gfdilxkfh7623nvndziyqnawkmo266a/path",
129
+ },
130
+ {
131
+ name: "legacy CID double-hash block (sha256)",
132
+ path: "/ipfs/bafkqahtcnrxwg23fmqqgi33vmjwgk2dbonuca3dfm5qwg6jamnuwicq",
133
+ },
134
+
135
+ {
136
+ name: "legacy Path double-hash block (sha256)",
137
+ path: "/ipfs/bafyaagyscufaqalqaacauaqiaejao43vmjygc5didacauaqiae/subpath",
138
+ },
139
+ }
140
+
141
+ // Which specific cliCmds we test against testCases
142
+ cliCmds := [][]string{
143
+ {"block", "get"},
144
+ {"block", "stat"},
145
+ {"dag", "get"},
146
+ {"dag", "export"},
147
+ {"dag", "stat"},
148
+ {"cat"},
149
+ {"ls"},
150
+ {"get"},
151
+ {"refs"},
152
+ }
153
+
154
+ expectedMsg := blockedMsg
155
+ for _, testCase := range testCases {
156
+
157
+ // Confirm that denylist is active for every command in 'cliCmds' x 'testCases'
158
+ for _, cmd := range cliCmds {
159
+ cmd := cmd
160
+ cliTestName := fmt.Sprintf("CLI '%s' denies %s", strings.Join(cmd, " "), testCase.name)
161
+ t.Run(cliTestName, func(t *testing.T) {
162
+ t.Parallel()
163
+ args := append(cmd, testCase.path)
164
+ errMsg := node.RunIPFS(args...).Stderr.Trimmed()
165
+ if !strings.Contains(errMsg, expectedMsg) {
166
+ t.Errorf("Expected STDERR error message %q, but got: %q", expectedMsg, errMsg)
167
+ }
168
+ })
169
+ }
170
+
171
+ // Confirm that denylist is active for every content path in 'testCases'
172
+ gwTestName := fmt.Sprintf("Gateway denies %s", testCase.name)
173
+ t.Run(gwTestName, func(t *testing.T) {
174
+ resp := client.Get(testCase.path)
175
+ assert.Equal(t, http.StatusGone, resp.StatusCode, statusExpl)
176
+ assert.Contains(t, resp.Body, blockedMsg, bodyExpl)
177
+ })
178
+
179
+ }
180
+
181
+ // Extra edge cases on subdomain gateway
182
+
183
+ t.Run("Gateway Denies /ipns Path that is blocked by DNSLink name (subdomain redirect)", func(t *testing.T) {
184
+ t.Parallel()
185
+
186
+ gwURL, _ := url.Parse(node.GatewayURL())
187
+ resp := client.Get("/ipns/blocked-dnslink.example.com", func(r *http.Request) {
188
+ r.Host = "localhost:" + gwURL.Port()
189
+ })
190
+
191
+ assert.Equal(t, http.StatusGone, resp.StatusCode, statusExpl)
192
+ assert.Contains(t, resp.Body, blockedMsg, bodyExpl)
193
+ })
194
+
195
+ t.Run("Gateway Denies /ipns Path that is blocked by DNSLink name (subdomain, no TLS)", func(t *testing.T) {
196
+ t.Parallel()
197
+
198
+ gwURL, _ := url.Parse(node.GatewayURL())
199
+ resp := client.Get("/", func(r *http.Request) {
200
+ r.Host = "blocked-dnslink.example.com.ipns.localhost:" + gwURL.Port()
201
+ })
202
+
203
+ assert.Equal(t, http.StatusGone, resp.StatusCode, statusExpl)
204
+ assert.Contains(t, resp.Body, blockedMsg, bodyExpl)
205
+ })
206
+
207
+ t.Run("Gateway Denies /ipns Path that is blocked by DNSLink name (subdomain, inlined for TLS)", func(t *testing.T) {
208
+ t.Parallel()
209
+
210
+ gwURL, _ := url.Parse(node.GatewayURL())
211
+ resp := client.Get("/", func(r *http.Request) {
212
+ // Inlined DNSLink to fit in single DNS label for TLS interop:
213
+ // https://specs.ipfs.tech/http-gateways/subdomain-gateway/#host-request-header
214
+ r.Host = "blocked--dnslink-example-com.ipns.localhost:" + gwURL.Port()
215
+ })
216
+
217
+ assert.Equal(t, http.StatusGone, resp.StatusCode, statusExpl)
218
+ assert.Contains(t, resp.Body, blockedMsg, bodyExpl)
219
+ })
220
+
221
+ // We need to confirm denylist is active when gateway is run in NoFetch
222
+ // mode (which usually swaps blockservice to a read-only one, and that swap
223
+ // may cause denylists to not be applied, as it is a separate code path)
224
+ t.Run("GatewayNoFetch", func(t *testing.T) {
225
+ // NOTE: we don't run this in parallel, as it requires restart with different config
226
+
227
+ // Switch gateway to NoFetch mode
228
+ node.StopDaemon()
229
+ node.IPFS("config", "--json", "Gateway.NoFetch", "true")
230
+ node.StartDaemon()
231
+
232
+ // update client, as the port of test node might've changed after restart
233
+ client = node.GatewayClient()
234
+
235
+ // First, confirm gateway works
236
+ t.Run("Allows CID that is not blocked", func(t *testing.T) {
237
+ resp := client.Get("/ipfs/" + allowedCID)
238
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
239
+ assert.Equal(t, "not blocked file content", resp.Body)
240
+ })
241
+
242
+ // Then, does the most basic blocking case work?
243
+ t.Run("Denies directly blocked CID", func(t *testing.T) {
244
+ resp := client.Get("/ipfs/" + blockedCID)
245
+ assert.Equal(t, http.StatusGone, resp.StatusCode, statusExpl)
246
+ assert.NotEqual(t, "directly blocked file content", resp.Body)
247
+ assert.Contains(t, resp.Body, blockedMsg, bodyExpl)
248
+ })
249
+
250
+ // Restore default
251
+ node.StopDaemon()
252
+ node.IPFS("config", "--json", "Gateway.NoFetch", "false")
253
+ node.StartDaemon()
254
+ client = node.GatewayClient()
255
+ })
256
+
257
+ // We need to confirm denylist is active on the
258
+ // trustless gateway exposed over libp2p
259
+ // when Experimental.GatewayOverLibp2p=true
260
+ // (https://github.com/ipfs/kubo/blob/master/docs/experimental-features.md#http-gateway-over-libp2p)
261
+ // NOTE: this type fo gateway is hardcoded to be NoFetch: it does not fetch
262
+ // data that is not in local store, so we only need to run it once: a
263
+ // simple smoke-test for allowed CID and blockedCID.
264
+ t.Run("GatewayOverLibp2p", func(t *testing.T) {
265
+ t.Parallel()
266
+
267
+ // Create libp2p client that connects to our node over
268
+ // /http1.1 and then talks gateway semantics over the /ipfs/gateway sub-protocol
269
+ clientHost, err := libp2p.New(libp2p.NoListenAddrs)
270
+ require.NoError(t, err)
271
+ err = clientHost.Connect(context.Background(), peer.AddrInfo{
272
+ ID: node.PeerID(),
273
+ Addrs: node.SwarmAddrs(),
274
+ })
275
+ require.NoError(t, err)
276
+
277
+ libp2pClient, err := (&libp2phttp.Host{StreamHost: clientHost}).NamespacedClient("/ipfs/gateway", peer.AddrInfo{ID: node.PeerID()})
278
+ require.NoError(t, err)
279
+
280
+ t.Run("Serves Allowed CID", func(t *testing.T) {
281
+ t.Parallel()
282
+ resp, err := libp2pClient.Get(fmt.Sprintf("/ipfs/%s?format=raw", allowedCID))
283
+ require.NoError(t, err)
284
+ defer resp.Body.Close()
285
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
286
+ body, err := io.ReadAll(resp.Body)
287
+ require.NoError(t, err)
288
+ require.Equal(t, string(body), "not blocked file content", bodyExpl)
289
+ })
290
+
291
+ t.Run("Denies Blocked CID", func(t *testing.T) {
292
+ t.Parallel()
293
+ resp, err := libp2pClient.Get(fmt.Sprintf("/ipfs/%s?format=raw", blockedCID))
294
+ require.NoError(t, err)
295
+ defer resp.Body.Close()
296
+ assert.Equal(t, http.StatusGone, resp.StatusCode, statusExpl)
297
+ body, err := io.ReadAll(resp.Body)
298
+ require.NoError(t, err)
299
+ assert.NotEqual(t, string(body), "directly blocked file content")
300
+ assert.Contains(t, string(body), blockedMsg, bodyExpl)
301
+ })
302
+ })
303
+}