fix dav autodiscovery: well-known needs every method, /dav needs no cors
two bugs, both of which iOS reports as "Cannot connect using SSL" — a message about TLS for a problem that has nothing to do with TLS. the certificate was never involved. the well-known routes were registered with .get. RFC 6764 §6 has the client probe the well-known URI with the method it actually intends to use, and iOS sends PROPFIND — which fell through to the SPA catch-all and 404'd. they answered correctly in a browser, which is why they looked healthy. hono's cors() answers every OPTIONS itself as a preflight and never calls the route beneath it, so OPTIONS /dav/ returned a bare 204 with no DAV header. OPTIONS is not a preflight to a DAV client — it is how the client asks what the server can do, and iOS refuses an account whose server does not advertise calendar-access. cors now skips the DAV paths entirely; a CalDAV client is not a browser and has no origin to check. verified from the public internet: PROPFIND on both well-known paths 301s to /dav/, and an authenticated OPTIONS now returns `DAV: 1, 2, 3, calendar-access, addressbook, extended-mkcol`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+30
-15
@@ -59,19 +59,31 @@ export type { HonoVariables };
|
|||||||
|
|
||||||
export const honoServer = new Hono<{ Variables: HonoVariables }>();
|
export const honoServer = new Hono<{ Variables: HonoVariables }>();
|
||||||
|
|
||||||
honoServer.use(
|
const corsMiddleware = cors({
|
||||||
cors({
|
origin: (origin, c) => {
|
||||||
origin: (origin, c) => {
|
const host = c.req.header('host');
|
||||||
const host = c.req.header('host');
|
// TEMPORARY: see isMusicOriginExempt — echoes any Origin back for /api/music when enabled, so a
|
||||||
// TEMPORARY: see isMusicOriginExempt — echoes any Origin back for /api/music when enabled, so a
|
// browser client is not blocked by CORS after userMiddleware has already let it through.
|
||||||
// browser client is not blocked by CORS after userMiddleware has already let it through.
|
if (isMusicOriginExempt(c.req.path)) return origin ?? '*';
|
||||||
if (isMusicOriginExempt(c.req.path)) return origin ?? '*';
|
return isOriginAllowed(origin, host) ? origin : '';
|
||||||
return isOriginAllowed(origin, host) ? origin : '';
|
},
|
||||||
},
|
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
allowHeaders: ['Content-Type', 'Authorization'],
|
||||||
allowHeaders: ['Content-Type', 'Authorization'],
|
});
|
||||||
}),
|
|
||||||
);
|
// The DAV doors are the one place CORS must NOT run, because hono's cors() answers every OPTIONS itself
|
||||||
|
// as a preflight — 204, no body, no DAV headers — and never calls the route beneath it.
|
||||||
|
//
|
||||||
|
// OPTIONS is not a preflight to a DAV client. It is how the client asks what the server can do, and the
|
||||||
|
// answer it needs is the `DAV: 1, 2, 3, calendar-access, addressbook` header. iOS sends it during account
|
||||||
|
// setup and refuses the account when it is missing, reporting the failure as "Cannot connect using SSL" —
|
||||||
|
// a message about TLS for a problem that has nothing to do with TLS, which is how this cost an evening.
|
||||||
|
//
|
||||||
|
// Nothing is lost by skipping it: a CalDAV client is not a browser and has no origin to check.
|
||||||
|
const isDavPath = (path: string) =>
|
||||||
|
path === '/dav' || path.startsWith('/dav/') || path === '/.well-known/caldav' || path === '/.well-known/carddav';
|
||||||
|
|
||||||
|
honoServer.use((ctx, next) => (isDavPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
|
||||||
|
|
||||||
// Scoped-origin gate: restrict app origins (e.g. the music app) to their allowed path prefixes
|
// Scoped-origin gate: restrict app origins (e.g. the music app) to their allowed path prefixes
|
||||||
// (/api/auth + /api/music). No-ops for the main web origin and while OFFICER_MUSIC_ORIGIN is unset.
|
// (/api/auth + /api/music). No-ops for the main web origin and while OFFICER_MUSIC_ORIGIN is unset.
|
||||||
@@ -99,8 +111,11 @@ honoServer.route('/dav', davSyncRouter);
|
|||||||
// credential, so they must sit above every auth gate. Without them iOS in particular degrades to
|
// credential, so they must sit above every auth gate. Without them iOS in particular degrades to
|
||||||
// demanding a full collection URL, which is exactly the sort of thing that makes self-hosting feel
|
// demanding a full collection URL, which is exactly the sort of thing that makes self-hosting feel
|
||||||
// worse than the commercial product it is replacing.
|
// worse than the commercial product it is replacing.
|
||||||
honoServer.get('/.well-known/caldav', (ctx) => ctx.redirect('/dav/', 301));
|
// `.all`, not `.get`: RFC 6764 §6 has the client probe the well-known URI with the method it actually
|
||||||
honoServer.get('/.well-known/carddav', (ctx) => ctx.redirect('/dav/', 301));
|
// wants to use, and iOS sends PROPFIND, not GET. Registered as GET-only these answered 404 to every real
|
||||||
|
// client while looking perfectly healthy in a browser.
|
||||||
|
honoServer.all('/.well-known/caldav', (ctx) => ctx.redirect('/dav/', 301));
|
||||||
|
honoServer.all('/.well-known/carddav', (ctx) => ctx.redirect('/dav/', 301));
|
||||||
|
|
||||||
const protectedRouter = createRouter();
|
const protectedRouter = createRouter();
|
||||||
protectedRouter.use(bodyParser());
|
protectedRouter.use(bodyParser());
|
||||||
|
|||||||
Reference in New Issue
Block a user