-
Notifications
You must be signed in to change notification settings - Fork 9
Reject non-JSON-LD PUT on .acl resources (#295) #339
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
Changes from all commits
ae8316c
fd411e3
388f31e
67571c6
04b22ee
9aca012
dab7b0f
c36885a
01415d8
03de3cc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -614,13 +614,35 @@ export async function handlePut(request, reply) { | |
|
|
||
| const contentType = request.headers['content-type'] || ''; | ||
|
|
||
| // ACL resources require a JSON-LD payload (application/ld+json or | ||
| // application/json). Round-trip serialization between JSON-LD and | ||
| // Turtle representations has limitations that can cause data loss | ||
| // when a client PUTs Turtle and later requests Turtle. | ||
| // Other RDF resources are unaffected. The guard fires regardless | ||
| // of conneg setting and also when Content-Type is missing. | ||
| const ctMain = contentType.split(';')[0].trim().toLowerCase(); | ||
| const isJsonLd = ctMain === 'application/ld+json' || ctMain === 'application/json'; | ||
| if (urlPath.endsWith('.acl') && !isJsonLd) { | ||
| reply.header('Accept', 'application/ld+json, application/json'); | ||
| reply.header('Accept-Put', 'application/ld+json, application/json'); | ||
| return reply.code(415).send({ | ||
| error: 'Unsupported Media Type', | ||
| message: 'ACL resources must be sent as application/ld+json or application/json.' | ||
| }); | ||
| } | ||
|
|
||
| // Check if we can accept this input type | ||
| if (!canAcceptInput(contentType, connegEnabled)) { | ||
| const acceptValue = connegEnabled | ||
| ? 'application/ld+json, application/json, text/turtle, text/n3' | ||
| : 'application/ld+json, application/json'; | ||
| reply.header('Accept', acceptValue); | ||
| reply.header('Accept-Put', acceptValue); | ||
| return reply.code(415).send({ | ||
| error: 'Unsupported Media Type', | ||
| message: connegEnabled | ||
| ? 'Supported types: application/ld+json, text/turtle, text/n3' | ||
| : 'Supported type: application/ld+json (enable conneg for Turtle support)' | ||
| ? 'Supported types: application/ld+json, application/json, text/turtle, text/n3' | ||
| : 'Supported types: application/ld+json, application/json (enable conneg for Turtle/N3 support)' | ||
| }); | ||
|
Comment on lines
634
to
646
|
||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -205,20 +205,33 @@ export function getVaryHeader(connegEnabled, mashlibEnabled = false) { | |
| } | ||
|
|
||
| /** | ||
| * Get Accept-* headers for responses | ||
| * Get Accept-* headers for responses. | ||
| * | ||
| * The explicitly listed RDF types are aligned with the formats this | ||
| * module accepts so clients can discover support consistently: | ||
| * - JSON-LD (application/ld+json) and JSON (application/json alias) | ||
| * are advertised in all conneg modes. | ||
| * - Turtle (text/turtle) and N3 (text/n3) are advertised only when | ||
| * conneg is enabled (SUPPORTED_INPUT in this file). | ||
| * | ||
| * Note: a wildcard (asterisk-slash-asterisk) is included as a broad | ||
| * interoperability hint for generic clients and proxies. It is not a | ||
| * strict contract that every media type matching the wildcard will be | ||
| * accepted by canAcceptInput() (e.g., application/n-triples and | ||
| * application/rdf+xml are not accepted). | ||
| */ | ||
| export function getAcceptHeaders(connegEnabled, isContainer = false) { | ||
| const headers = {}; | ||
|
|
||
| if (isContainer) { | ||
| headers['Accept-Post'] = connegEnabled | ||
| ? `${RDF_TYPES.JSON_LD}, ${RDF_TYPES.TURTLE}, */*` | ||
| : `${RDF_TYPES.JSON_LD}, */*`; | ||
| ? `${RDF_TYPES.JSON_LD}, application/json, ${RDF_TYPES.TURTLE}, ${RDF_TYPES.N3}, */*` | ||
| : `${RDF_TYPES.JSON_LD}, application/json, */*`; | ||
| } | ||
|
|
||
| headers['Accept-Put'] = connegEnabled | ||
| ? `${RDF_TYPES.JSON_LD}, ${RDF_TYPES.TURTLE}, */*` | ||
| : `${RDF_TYPES.JSON_LD}, */*`; | ||
| ? `${RDF_TYPES.JSON_LD}, application/json, ${RDF_TYPES.TURTLE}, ${RDF_TYPES.N3}, */*` | ||
| : `${RDF_TYPES.JSON_LD}, application/json, */*`; | ||
|
Comment on lines
227
to
+234
|
||
|
|
||
|
Comment on lines
226
to
235
|
||
| headers['Accept-Patch'] = 'text/n3, application/sparql-update'; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -193,6 +193,34 @@ describe('Content Negotiation (conneg enabled)', () => { | |||||||||||||||||
| assert.ok(acceptPost && acceptPost.includes('text/turtle'), | ||||||||||||||||||
| 'Accept-Post should include text/turtle'); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| it('should advertise N3 support in Accept-Put when conneg enabled', async () => { | ||||||||||||||||||
| const res = await request('/connegtest/public/alice.json'); | ||||||||||||||||||
| const acceptPut = res.headers.get('Accept-Put'); | ||||||||||||||||||
| assert.ok(acceptPut && acceptPut.includes('text/n3'), | ||||||||||||||||||
| 'Accept-Put should include text/n3 (canAcceptInput accepts it under conneg)'); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| it('should advertise N3 support in Accept-Post for containers when conneg enabled', async () => { | ||||||||||||||||||
| const res = await request('/connegtest/public/'); | ||||||||||||||||||
| const acceptPost = res.headers.get('Accept-Post'); | ||||||||||||||||||
| assert.ok(acceptPost && acceptPost.includes('text/n3'), | ||||||||||||||||||
| 'Accept-Post should include text/n3 (canAcceptInput accepts it under conneg)'); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| it('should advertise application/json in Accept-Put', async () => { | ||||||||||||||||||
| const res = await request('/connegtest/public/alice.json'); | ||||||||||||||||||
| const acceptPut = res.headers.get('Accept-Put'); | ||||||||||||||||||
| assert.ok(acceptPut && acceptPut.includes('application/json'), | ||||||||||||||||||
| 'Accept-Put should include application/json (canAcceptInput treats it as a JSON-LD alias)'); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| it('should advertise application/json in Accept-Post for containers', async () => { | ||||||||||||||||||
| const res = await request('/connegtest/public/'); | ||||||||||||||||||
| const acceptPost = res.headers.get('Accept-Post'); | ||||||||||||||||||
| assert.ok(acceptPost && acceptPost.includes('application/json'), | ||||||||||||||||||
| 'Accept-Post should include application/json (canAcceptInput treats it as a JSON-LD alias)'); | ||||||||||||||||||
| }); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| // Regression coverage for #294 — Solid convention dotfiles (.acl, .meta) | ||||||||||||||||||
|
|
@@ -261,6 +289,107 @@ describe('Content Negotiation (conneg enabled)', () => { | |||||||||||||||||
| 'round-tripped JSON-LD should have at least one @-keyword'); | ||||||||||||||||||
| }); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| // ACL resources require a JSON-LD payload (application/ld+json or | ||||||||||||||||||
| // application/json) on PUT regardless of conneg setting: round-trip | ||||||||||||||||||
| // serialization between JSON-LD and Turtle has known limitations | ||||||||||||||||||
| // that can cause data loss. See #295. | ||||||||||||||||||
| describe('ACL content-type guard (#295)', () => { | ||||||||||||||||||
| const aclJsonLd = { | ||||||||||||||||||
| '@context': { acl: 'http://www.w3.org/ns/auth/acl#' }, | ||||||||||||||||||
| '@graph': [ | ||||||||||||||||||
| { | ||||||||||||||||||
| '@id': '#owner', | ||||||||||||||||||
| '@type': 'acl:Authorization', | ||||||||||||||||||
| 'acl:agent': { '@id': '#me' }, | ||||||||||||||||||
| 'acl:accessTo': { '@id': './' }, | ||||||||||||||||||
| 'acl:mode': [{ '@id': 'acl:Read' }, { '@id': 'acl:Write' }, { '@id': 'acl:Control' }] | ||||||||||||||||||
| } | ||||||||||||||||||
| ] | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| it('rejects text/turtle PUT to .acl with 415', async () => { | ||||||||||||||||||
| const turtle = ` | ||||||||||||||||||
| @prefix acl: <http://www.w3.org/ns/auth/acl#>. | ||||||||||||||||||
| <#owner> a acl:Authorization; | ||||||||||||||||||
| acl:mode acl:Read. | ||||||||||||||||||
| `; | ||||||||||||||||||
| const res = await request('/connegtest/public/turtle-reject.acl', { | ||||||||||||||||||
| method: 'PUT', | ||||||||||||||||||
| headers: { 'Content-Type': 'text/turtle' }, | ||||||||||||||||||
| body: turtle, | ||||||||||||||||||
| auth: 'connegtest' | ||||||||||||||||||
| }); | ||||||||||||||||||
| assertStatus(res, 415); | ||||||||||||||||||
| assertHeaderContains(res, 'Accept', 'application/ld+json'); | ||||||||||||||||||
| assertHeaderContains(res, 'Accept', 'application/json'); | ||||||||||||||||||
| assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); | ||||||||||||||||||
| assertHeaderContains(res, 'Accept-Put', 'application/json'); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
Comment on lines
+323
to
+328
|
||||||||||||||||||
|
|
||||||||||||||||||
| it('rejects text/n3 PUT to .acl with 415', async () => { | ||||||||||||||||||
| const res = await request('/connegtest/public/n3-reject.acl', { | ||||||||||||||||||
| method: 'PUT', | ||||||||||||||||||
| headers: { 'Content-Type': 'text/n3' }, | ||||||||||||||||||
| body: '@prefix acl: <http://www.w3.org/ns/auth/acl#>. <#x> a acl:Authorization.', | ||||||||||||||||||
| auth: 'connegtest' | ||||||||||||||||||
| }); | ||||||||||||||||||
| assertStatus(res, 415); | ||||||||||||||||||
| assertHeaderContains(res, 'Accept', 'application/ld+json'); | ||||||||||||||||||
| assertHeaderContains(res, 'Accept', 'application/json'); | ||||||||||||||||||
| assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); | ||||||||||||||||||
| assertHeaderContains(res, 'Accept-Put', 'application/json'); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
Comment on lines
+330
to
+342
|
||||||||||||||||||
|
|
||||||||||||||||||
| it('rejects text/plain PUT to .acl with 415 (URL-extension protection)', async () => { | ||||||||||||||||||
| const res = await request('/connegtest/public/plain-reject.acl', { | ||||||||||||||||||
| method: 'PUT', | ||||||||||||||||||
| headers: { 'Content-Type': 'text/plain' }, | ||||||||||||||||||
| body: 'arbitrary text', | ||||||||||||||||||
| auth: 'connegtest' | ||||||||||||||||||
| }); | ||||||||||||||||||
| assertStatus(res, 415); | ||||||||||||||||||
| assertHeaderContains(res, 'Accept', 'application/ld+json'); | ||||||||||||||||||
| assertHeaderContains(res, 'Accept', 'application/json'); | ||||||||||||||||||
| assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); | ||||||||||||||||||
| assertHeaderContains(res, 'Accept-Put', 'application/json'); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| it('rejects PUT to .acl with no Content-Type with 415', async () => { | ||||||||||||||||||
| // Use Uint8Array body so fetch() doesn't auto-set Content-Type | ||||||||||||||||||
| // (which it does for string bodies: text/plain;charset=UTF-8). | ||||||||||||||||||
| const res = await request('/connegtest/public/no-ct-reject.acl', { | ||||||||||||||||||
| method: 'PUT', | ||||||||||||||||||
| body: new Uint8Array([1, 2, 3, 4]), | ||||||||||||||||||
| auth: 'connegtest' | ||||||||||||||||||
| }); | ||||||||||||||||||
| assertStatus(res, 415); | ||||||||||||||||||
| assertHeaderContains(res, 'Accept', 'application/ld+json'); | ||||||||||||||||||
| assertHeaderContains(res, 'Accept', 'application/json'); | ||||||||||||||||||
| assertHeaderContains(res, 'Accept-Put', 'application/ld+json'); | ||||||||||||||||||
| assertHeaderContains(res, 'Accept-Put', 'application/json'); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
Comment on lines
+358
to
+371
|
||||||||||||||||||
|
|
||||||||||||||||||
| it('accepts application/ld+json PUT to .acl', async () => { | ||||||||||||||||||
| const res = await request('/connegtest/public/jsonld-accept.acl', { | ||||||||||||||||||
| method: 'PUT', | ||||||||||||||||||
| headers: { 'Content-Type': 'application/ld+json' }, | ||||||||||||||||||
| body: JSON.stringify(aclJsonLd), | ||||||||||||||||||
| auth: 'connegtest' | ||||||||||||||||||
| }); | ||||||||||||||||||
| assert.ok(res.status < 300, `JSON-LD PUT to .acl should succeed, got ${res.status}`); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| it('accepts application/json PUT to .acl', async () => { | ||||||||||||||||||
| const res = await request('/connegtest/public/json-accept.acl', { | ||||||||||||||||||
| method: 'PUT', | ||||||||||||||||||
| headers: { 'Content-Type': 'application/json' }, | ||||||||||||||||||
| body: JSON.stringify(aclJsonLd), | ||||||||||||||||||
| auth: 'connegtest' | ||||||||||||||||||
| }); | ||||||||||||||||||
| assert.ok(res.status < 300, `application/json PUT to .acl should succeed, got ${res.status}`); | ||||||||||||||||||
| }); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
Comment on lines
+297
to
+392
|
||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| describe('Content Negotiation (conneg disabled - default)', () => { | ||||||||||||||||||
|
|
@@ -352,6 +481,109 @@ describe('Content Negotiation (conneg disabled - default)', () => { | |||||||||||||||||
| assert.ok(!acceptPut || !acceptPut.includes('text/turtle'), | ||||||||||||||||||
| 'Accept-Put should NOT include text/turtle when conneg disabled'); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| it('should advertise application/json in Accept-Put when conneg disabled', async () => { | ||||||||||||||||||
| const res = await request('/noconneg/public/'); | ||||||||||||||||||
| const acceptPut = res.headers.get('Accept-Put'); | ||||||||||||||||||
| assert.ok(acceptPut && acceptPut.includes('application/json'), | ||||||||||||||||||
| 'Accept-Put should include application/json (canAcceptInput treats it as a JSON-LD alias)'); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
|
||||||||||||||||||
| it('should advertise application/json in Accept-Post when conneg disabled', async () => { | |
| const res = await request('/noconneg/public/'); | |
| const acceptPost = res.headers.get('Accept-Post'); | |
| assert.ok(acceptPost && acceptPost.includes('application/json'), | |
| 'Accept-Post should include application/json (canAcceptInput treats it as a JSON-LD alias)'); | |
| }); |
Copilot
AI
Apr 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PR description says “add 5 test cases”, but this change adds more (a full ACL guard suite under conneg enabled plus additional coverage under conneg disabled, including missing Content-Type cases). Please update the PR description/test counts so reviewers and release notes match the actual scope.
Copilot
AI
Apr 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the conneg-disabled guard tests, the 415 assertions currently only check Accept. Since the implementation adds Accept-Put as well, consider asserting Accept-Put here too so the default deployment mode covers the full response contract.
Copilot
AI
Apr 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same issue here: passing a string body to fetch() will typically cause it to infer Content-Type: text/plain;charset=UTF-8, so this test may not cover the truly-missing Content-Type case. Use a Buffer/Uint8Array body (and avoid setting Content-Type) to ensure the request is sent without the header.
Copilot
AI
Apr 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The handler advertises application/json as an allowed ACL PUT media type; conneg-enabled tests cover this, but the conneg-disabled guard tests don’t. Adding an application/json acceptance test in this conneg-disabled block would ensure the advertised type is actually supported in the default mode.
Copilot
AI
Apr 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This conneg-disabled ACL guard block includes an application/json acceptance test, which means the PR adds 5 conneg-disabled cases (and 11 total across enabled+disabled), not the 4/10 described in the PR description. Please align the PR description/test counts (or drop/adjust this test) so reviewers aren’t reconciling conflicting documentation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor inconsistency: this branch treats
application/jsonas an allowed JSON-LD payload (and advertises it), but the later genericcanAcceptInput()415 error message in this handler still only listsapplication/ld+jsonas supported. Consider updating that other 415 message to also mentionapplication/jsonso clients get accurate guidance across both paths (canAcceptInput already acceptsapplication/json).